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)]
615 pub interfaces: Option<AgentInterfaces>,
616
617 #[serde(default)]
625 pub display_name: Option<String>,
626
627 #[serde(default)]
636 pub hooks: Vec<HookSpec>,
637
638 #[serde(default)]
646 pub mcps: BTreeMap<String, McpServer>,
647
648 #[serde(default)]
661 pub subagents: Vec<PathBuf>,
662
663 #[serde(default)]
676 pub skills: Vec<PathBuf>,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, Default)]
682pub struct AgentInterfaces {
683 #[serde(default)]
688 pub telegram: Option<TelegramConfig>,
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct TelegramConfig {
695 pub bot_token_env: String,
698 pub chat_ids_env: String,
701 #[serde(default)]
706 pub speech_to_text: Option<SttConfig>,
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
713pub struct SttConfig {
714 pub provider: String,
716 pub api_key_env: String,
720 pub model: String,
722 #[serde(default)]
725 pub language: Option<String>,
726}
727
728impl Agent {
729 pub fn telegram(&self) -> Option<&TelegramConfig> {
733 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
734 }
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, Default)]
742pub struct ProjectInterfaces {
743 #[serde(default)]
750 pub telegram: Option<ProjectTelegramConfig>,
751}
752
753#[derive(Debug, Clone, Serialize, Deserialize, Default)]
759pub struct ProjectTelegramConfig {
760 #[serde(default)]
765 pub manager_bot: Option<ManagerBotConfig>,
766
767 #[serde(default)]
773 pub profile_picture: Option<ProfilePictureConfig>,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize)]
780pub struct ManagerBotConfig {
781 pub token_env: String,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, Default)]
794pub struct ProfilePictureConfig {
795 #[serde(default)]
801 pub image_model: Option<ImageModelConfig>,
802
803 #[serde(default)]
808 pub fallback: ProfilePictureFallback,
809}
810
811#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
816#[serde(rename_all = "lowercase")]
817pub enum ProfilePictureFallback {
818 #[default]
822 Initials,
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct ImageModelConfig {
831 pub provider: String,
833 pub api_key_env: String,
838 pub model: String,
841}
842
843impl Project {
844 pub fn telegram(&self) -> Option<&ProjectTelegramConfig> {
848 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
849 }
850}
851
852#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
856#[serde(rename_all = "lowercase")]
857pub enum EffortLevel {
858 Low,
859 Medium,
860 High,
861 Xhigh,
862 Max,
863}
864
865impl EffortLevel {
866 pub fn as_str(self) -> &'static str {
869 match self {
870 EffortLevel::Low => "low",
871 EffortLevel::Medium => "medium",
872 EffortLevel::High => "high",
873 EffortLevel::Xhigh => "xhigh",
874 EffortLevel::Max => "max",
875 }
876 }
877}
878
879fn default_runtime() -> String {
880 "claude-code".into()
881}
882
883fn default_autonomy() -> String {
884 "low_risk_only".into()
885}
886
887#[derive(Debug, Clone)]
889pub struct Compose {
890 pub root: PathBuf,
891 pub global: Global,
892 pub projects: Vec<Project>,
893}
894
895impl Compose {
896 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
907 let start = start
908 .canonicalize()
909 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
910 let mut cur: Option<&Path> = Some(&start);
911 while let Some(dir) = cur {
912 let candidate = dir.join(".team").join("team-compose.yaml");
913 if candidate.is_file() {
914 return Ok(dir.join(".team"));
915 }
916 cur = dir.parent();
917 }
918 Err(anyhow::anyhow!(
919 "no `.team/team-compose.yaml` found in {} or any parent",
920 start.display()
921 ))
922 }
923
924 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
926 let root = root.as_ref().to_path_buf();
927 let global_path = root.join("team-compose.yaml");
928 let raw = std::fs::read_to_string(&global_path)
929 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?;
930 let global: Global = serde_yaml::from_str(&raw)
931 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
932
933 if global.version.from_legacy_int {
950 if let Err(e) = rewrite_legacy_version_in_file(&global_path, &raw) {
951 tracing::warn!(
952 target: "team-core::compose",
953 "could not rewrite legacy `version: 2` in {}: {e}; \
954 proceeding with in-memory `\"2.0.0\"`",
955 global_path.display()
956 );
957 }
958 }
959
960 let mut projects = Vec::with_capacity(global.projects.len());
961 for r in &global.projects {
962 let p = root.join(&r.file);
963 let parsed: Project = serde_yaml::from_str(
964 &std::fs::read_to_string(&p)
965 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
966 )
967 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
968 projects.push(parsed);
969 }
970
971 Ok(Self {
972 root,
973 global,
974 projects,
975 })
976 }
977
978 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
980 self.projects.iter().flat_map(|p| {
981 p.managers
982 .iter()
983 .map(move |(id, a)| AgentHandle {
984 project: &p.project.id,
985 agent: id,
986 spec: a,
987 is_manager: true,
988 })
989 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
990 project: &p.project.id,
991 agent: id,
992 spec: a,
993 is_manager: false,
994 }))
995 })
996 }
997}
998
999fn rewrite_legacy_version_in_file(path: &Path, raw: &str) -> anyhow::Result<()> {
1005 let updated = crate::yaml_edit::set_top_level_scalar(raw, "version", "\"2.0.0\"")?;
1006 std::fs::write(path, updated).map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
1007 Ok(())
1008}
1009
1010#[derive(Debug, Clone, Copy)]
1011pub struct AgentHandle<'a> {
1012 pub project: &'a str,
1013 pub agent: &'a str,
1014 pub spec: &'a Agent,
1015 pub is_manager: bool,
1016}
1017
1018impl AgentHandle<'_> {
1019 pub fn id(&self) -> String {
1021 format!("{}:{}", self.project, self.agent)
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028
1029 #[test]
1030 fn channel_members_all_expands() {
1031 let all = ChannelMembers::All("*".into());
1032 assert!(all.includes("dev1", &["dev1", "dev2"]));
1033 assert!(!all.includes("ghost", &["dev1", "dev2"]));
1034 }
1035
1036 #[test]
1037 fn channel_members_explicit_checks_list() {
1038 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
1039 assert!(exp.includes("dev1", &[]));
1040 assert!(!exp.includes("dev2", &[]));
1041 }
1042
1043 #[test]
1044 fn agent_defaults_are_stable() {
1045 let a: Agent = serde_yaml::from_str("model: claude-opus-4-8\n").unwrap();
1046 assert_eq!(a.runtime, "claude-code");
1047 assert_eq!(a.autonomy, "low_risk_only");
1048 assert!(a.interfaces.is_none());
1049 assert!(a.telegram().is_none());
1050 assert!(a.effort.is_none());
1051 }
1052
1053 #[test]
1054 fn agent_telegram_block_parses_under_interfaces() {
1055 let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
1056 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1057 let tg = a.telegram().expect("telegram parsed");
1058 assert_eq!(tg.bot_token_env, "T");
1059 assert_eq!(tg.chat_ids_env, "C");
1060 assert!(tg.speech_to_text.is_none());
1061 }
1062
1063 #[test]
1064 fn agent_telegram_block_parses_speech_to_text() {
1065 let yaml = "\
1066interfaces:
1067 telegram:
1068 bot_token_env: T
1069 chat_ids_env: C
1070 speech_to_text:
1071 provider: groq
1072 api_key_env: GROQ_API_KEY
1073 model: whisper-large-v3
1074 language: en
1075";
1076 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1077 let stt = a
1078 .telegram()
1079 .and_then(|t| t.speech_to_text.as_ref())
1080 .expect("speech_to_text parsed");
1081 assert_eq!(stt.provider, "groq");
1082 assert_eq!(stt.api_key_env, "GROQ_API_KEY");
1083 assert_eq!(stt.model, "whisper-large-v3");
1084 assert_eq!(stt.language.as_deref(), Some("en"));
1085 }
1086
1087 #[test]
1088 fn agent_telegram_block_parses_speech_to_text_without_language() {
1089 let yaml = "\
1090interfaces:
1091 telegram:
1092 bot_token_env: T
1093 chat_ids_env: C
1094 speech_to_text:
1095 provider: groq
1096 api_key_env: K
1097 model: whisper-large-v3
1098";
1099 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1100 let stt = a
1101 .telegram()
1102 .and_then(|t| t.speech_to_text.as_ref())
1103 .expect("speech_to_text parsed");
1104 assert!(stt.language.is_none());
1105 }
1106
1107 #[test]
1108 fn effort_parses_all_five_levels() {
1109 for (yaml, expected) in [
1110 ("effort: low\n", EffortLevel::Low),
1111 ("effort: medium\n", EffortLevel::Medium),
1112 ("effort: high\n", EffortLevel::High),
1113 ("effort: xhigh\n", EffortLevel::Xhigh),
1114 ("effort: max\n", EffortLevel::Max),
1115 ] {
1116 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
1117 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
1118 }
1119 }
1120
1121 #[test]
1122 fn effort_unknown_value_is_rejected() {
1123 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
1124 .expect_err("typo'd effort value must fail to parse");
1125 let msg = err.to_string();
1126 assert!(
1127 msg.contains("low") && msg.contains("max"),
1128 "error should enumerate valid variants; got: {msg}"
1129 );
1130 }
1131
1132 #[test]
1133 fn effort_renders_to_lowercase_string() {
1134 assert_eq!(EffortLevel::Low.as_str(), "low");
1135 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
1136 assert_eq!(EffortLevel::Max.as_str(), "max");
1137 }
1138
1139 #[test]
1140 fn discover_prefers_dot_team() {
1141 let tmp = tempfile::tempdir().unwrap();
1142 let repo = tmp.path();
1143 std::fs::create_dir_all(repo.join(".team")).unwrap();
1144 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1145 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
1147
1148 let sub = repo.join("src/deep/nested");
1150 std::fs::create_dir_all(&sub).unwrap();
1151 let found = Compose::discover(&sub).unwrap();
1152 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
1153 }
1154
1155 #[test]
1156 fn discover_no_longer_falls_back_to_flat_layout() {
1157 let tmp = tempfile::tempdir().unwrap();
1161 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
1162 let err = Compose::discover(tmp.path()).unwrap_err();
1163 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1164 }
1165
1166 #[test]
1167 fn discover_returns_first_dot_team_walking_up() {
1168 let tmp = tempfile::tempdir().unwrap();
1171 let outer = tmp.path();
1172 let inner = outer.join("packages/inner");
1173 std::fs::create_dir_all(outer.join(".team")).unwrap();
1174 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1175 std::fs::create_dir_all(inner.join(".team")).unwrap();
1176 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1177
1178 let from_inner = inner.join("src/deep");
1179 std::fs::create_dir_all(&from_inner).unwrap();
1180 let found = Compose::discover(&from_inner).unwrap();
1181 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
1182 }
1183
1184 #[test]
1185 fn discover_errors_when_nothing_found() {
1186 let tmp = tempfile::tempdir().unwrap();
1187 let err = Compose::discover(tmp.path()).unwrap_err();
1188 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1189 }
1190
1191 #[test]
1192 fn role_prompt_parses_single_string_form() {
1193 let yaml = "role_prompt: roles/mgr.md\n";
1194 let agent: Agent = serde_yaml::from_str(&format!(
1195 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1196 ))
1197 .unwrap();
1198 match agent.role_prompt.unwrap() {
1199 RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
1200 other => panic!("expected Single, got {other:?}"),
1201 }
1202 }
1203
1204 #[test]
1205 fn role_prompt_parses_list_form() {
1206 let yaml = "role_prompt:\n - roles/_base.md\n - roles/mgr.md\n";
1207 let agent: Agent = serde_yaml::from_str(&format!(
1208 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1209 ))
1210 .unwrap();
1211 match agent.role_prompt.unwrap() {
1212 RolePrompt::Multiple(v) => assert_eq!(
1213 v,
1214 vec![
1215 PathBuf::from("roles/_base.md"),
1216 PathBuf::from("roles/mgr.md"),
1217 ]
1218 ),
1219 other => panic!("expected Multiple, got {other:?}"),
1220 }
1221 }
1222
1223 #[test]
1224 fn role_prompt_paths_returns_declared_order() {
1225 let rp = RolePrompt::Multiple(vec![
1226 PathBuf::from("a.md"),
1227 PathBuf::from("b.md"),
1228 PathBuf::from("c.md"),
1229 ]);
1230 let got: Vec<&Path> = rp.paths();
1231 assert_eq!(
1232 got,
1233 vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
1234 );
1235 }
1236
1237 #[test]
1245 fn schema_version_accepts_semver_string() {
1246 let v: SchemaVersion = serde_yaml::from_str("\"2.0.0\"").unwrap();
1247 assert_eq!(v.value, "2.0.0");
1248 assert!(!v.from_legacy_int, "string form is NOT the legacy path");
1249 }
1250
1251 #[test]
1252 fn schema_version_accepts_arbitrary_semver_string_for_later_validation() {
1253 let v: SchemaVersion = serde_yaml::from_str("\"abc\"").unwrap();
1258 assert_eq!(v.value, "abc");
1259 }
1260
1261 #[test]
1262 fn schema_version_coerces_legacy_integer_two() {
1263 let v: SchemaVersion = serde_yaml::from_str("2").unwrap();
1264 assert_eq!(v.value, "2.0.0");
1265 assert!(v.from_legacy_int, "integer-2 must be flagged for rewrite");
1266 }
1267
1268 #[test]
1269 fn schema_version_rejects_other_integers() {
1270 for n in [0u64, 1, 3, 99] {
1274 let err = serde_yaml::from_str::<SchemaVersion>(&n.to_string())
1275 .expect_err("non-2 integer must fail");
1276 let msg = err.to_string();
1277 assert!(
1278 msg.contains("only legacy `2` is auto-coerced"),
1279 "error must name the constraint; got: {msg}"
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn schema_version_rejects_non_string_non_int_shapes() {
1286 for yaml in ["true", "[1,2,3]", "{a: b}"] {
1288 let res = serde_yaml::from_str::<SchemaVersion>(yaml);
1289 assert!(res.is_err(), "yaml `{yaml}` must fail to deserialize");
1290 }
1291 }
1292
1293 #[test]
1300 fn load_rewrites_legacy_version_two_in_file_and_in_memory() {
1301 let tmp = tempfile::tempdir().unwrap();
1302 let root = tmp.path().join(".team");
1303 std::fs::create_dir_all(&root).unwrap();
1304 let yaml = "\
1305# T-265 fixture — legacy version
1306version: 2
1307broker:
1308 type: sqlite
1309 path: state/mailbox.db
1310";
1311 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1312 let compose = Compose::load(&root).expect("load succeeds on legacy file");
1313 assert_eq!(compose.global.version.value, "2.0.0");
1315 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1317 assert!(
1318 after.contains("version: \"2.0.0\""),
1319 "file must be rewritten;\n{after}"
1320 );
1321 assert!(
1322 !after.contains("\nversion: 2\n"),
1323 "no legacy literal must survive;\n{after}"
1324 );
1325 assert!(
1327 after.contains("# T-265 fixture"),
1328 "comment must survive the rewrite;\n{after}"
1329 );
1330 assert!(after.contains("type: sqlite"));
1332 }
1333
1334 #[test]
1335 fn load_leaves_canonical_semver_file_untouched() {
1336 let tmp = tempfile::tempdir().unwrap();
1337 let root = tmp.path().join(".team");
1338 std::fs::create_dir_all(&root).unwrap();
1339 let yaml = "\
1340version: \"2.0.0\"
1341broker:
1342 type: sqlite
1343";
1344 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1345 let compose = Compose::load(&root).expect("load succeeds");
1346 assert_eq!(compose.global.version.value, "2.0.0");
1347 assert!(
1348 !compose.global.version.from_legacy_int,
1349 "canonical file must NOT be flagged for rewrite"
1350 );
1351 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1353 assert_eq!(after, yaml, "canonical file must NOT be mutated on load");
1354 }
1355
1356 #[test]
1357 fn load_hard_errors_on_non_two_integer_version() {
1358 let tmp = tempfile::tempdir().unwrap();
1359 let root = tmp.path().join(".team");
1360 std::fs::create_dir_all(&root).unwrap();
1361 std::fs::write(root.join("team-compose.yaml"), "version: 3\n").unwrap();
1362 let err = Compose::load(&root).expect_err("must reject integer-3 at parse");
1363 assert!(
1364 err.to_string().contains("only legacy `2` is auto-coerced"),
1365 "error must name the constraint; got: {err}"
1366 );
1367 }
1368
1369 const PROJECT_YAML_HEAD: &str = "\
1376version: 2
1377project:
1378 id: p
1379 name: P
1380 cwd: .
1381";
1382
1383 #[test]
1384 fn project_without_interfaces_block_parses_unchanged() {
1385 let p: Project = serde_yaml::from_str(PROJECT_YAML_HEAD).unwrap();
1388 assert!(p.interfaces.is_none());
1389 assert!(p.telegram().is_none());
1390 }
1391
1392 #[test]
1393 fn project_telegram_block_parses_under_interfaces() {
1394 let yaml = format!(
1398 "{PROJECT_YAML_HEAD}\
1399interfaces:
1400 telegram:
1401 manager_bot:
1402 token_env: TEAMCTL_TG_MANAGER_TOKEN
1403 profile_picture:
1404 image_model:
1405 provider: openai
1406 api_key_env: OPENAI_API_KEY
1407 model: gpt-image-2
1408 fallback: initials
1409"
1410 );
1411 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1412 let tg = p.telegram().expect("project telegram parsed");
1413 let mb = tg.manager_bot.as_ref().expect("manager_bot parsed");
1414 assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
1415 let pp = tg.profile_picture.as_ref().expect("profile_picture parsed");
1416 let im = pp.image_model.as_ref().expect("image_model parsed");
1417 assert_eq!(im.provider, "openai");
1418 assert_eq!(im.api_key_env, "OPENAI_API_KEY");
1419 assert_eq!(im.model, "gpt-image-2");
1420 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1421 }
1422
1423 #[test]
1424 fn project_telegram_block_parses_manager_bot_only() {
1425 let yaml = format!(
1430 "{PROJECT_YAML_HEAD}\
1431interfaces:
1432 telegram:
1433 manager_bot:
1434 token_env: TEAMCTL_TG_MANAGER_TOKEN
1435"
1436 );
1437 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1438 let tg = p.telegram().expect("project telegram parsed");
1439 assert_eq!(
1440 tg.manager_bot.as_ref().unwrap().token_env,
1441 "TEAMCTL_TG_MANAGER_TOKEN"
1442 );
1443 assert!(tg.profile_picture.is_none());
1444 }
1445
1446 #[test]
1447 fn profile_picture_fallback_defaults_to_initials_when_omitted() {
1448 let yaml = format!(
1452 "{PROJECT_YAML_HEAD}\
1453interfaces:
1454 telegram:
1455 profile_picture:
1456 image_model:
1457 provider: openai
1458 api_key_env: OPENAI_API_KEY
1459 model: gpt-image-2
1460"
1461 );
1462 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1463 let pp = p
1464 .telegram()
1465 .and_then(|t| t.profile_picture.as_ref())
1466 .expect("profile_picture parsed");
1467 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1468 }
1469
1470 #[test]
1471 fn profile_picture_image_model_optional_with_initials_fallback() {
1472 let yaml = format!(
1476 "{PROJECT_YAML_HEAD}\
1477interfaces:
1478 telegram:
1479 profile_picture:
1480 fallback: initials
1481"
1482 );
1483 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1484 let pp = p
1485 .telegram()
1486 .and_then(|t| t.profile_picture.as_ref())
1487 .expect("profile_picture parsed");
1488 assert!(pp.image_model.is_none());
1489 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1490 }
1491
1492 #[test]
1493 fn manager_bot_missing_token_env_rejected() {
1494 let yaml = format!(
1498 "{PROJECT_YAML_HEAD}\
1499interfaces:
1500 telegram:
1501 manager_bot: {{}}
1502"
1503 );
1504 let err =
1505 serde_yaml::from_str::<Project>(&yaml).expect_err("malformed manager_bot must reject");
1506 assert!(
1507 err.to_string().contains("token_env"),
1508 "error must name the missing field: {err}"
1509 );
1510 }
1511
1512 #[test]
1513 fn image_model_missing_required_fields_rejected() {
1514 for (label, yaml_fragment) in [
1518 ("missing provider", "api_key_env: K\n model: M"),
1519 ("missing api_key_env", "provider: openai\n model: M"),
1520 ("missing model", "provider: openai\n api_key_env: K"),
1521 ] {
1522 let yaml = format!(
1523 "{PROJECT_YAML_HEAD}\
1524interfaces:
1525 telegram:
1526 profile_picture:
1527 image_model:
1528 {yaml_fragment}
1529"
1530 );
1531 let result = serde_yaml::from_str::<Project>(&yaml);
1532 assert!(
1533 result.is_err(),
1534 "malformed image_model ({label}) must reject, got: {result:?}"
1535 );
1536 }
1537 }
1538
1539 #[test]
1540 fn profile_picture_fallback_unknown_value_rejected() {
1541 let yaml = format!(
1545 "{PROJECT_YAML_HEAD}\
1546interfaces:
1547 telegram:
1548 profile_picture:
1549 fallback: emoji
1550"
1551 );
1552 let err = serde_yaml::from_str::<Project>(&yaml)
1553 .expect_err("unknown fallback variant must reject");
1554 assert!(
1555 err.to_string().contains("emoji") || err.to_string().contains("variant"),
1556 "error must explain the unknown variant: {err}"
1557 );
1558 }
1559}