1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34#[serde(transparent)]
35pub struct SchemaVersion {
36 pub value: String,
37 #[serde(skip)]
38 pub from_legacy_int: bool,
39}
40
41impl SchemaVersion {
42 pub const CURRENT: &str = "2.0.1";
46
47 pub fn new(value: impl Into<String>) -> Self {
50 Self {
51 value: value.into(),
52 from_legacy_int: false,
53 }
54 }
55}
56
57impl JsonSchema for SchemaVersion {
61 fn schema_name() -> String {
62 "SchemaVersion".to_string()
63 }
64
65 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
66 gen.subschema_for::<String>()
67 }
68}
69
70impl<'de> Deserialize<'de> for SchemaVersion {
71 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
72 use serde::de::{self, Visitor};
73 struct V;
74 impl<'de> Visitor<'de> for V {
75 type Value = SchemaVersion;
76 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
77 f.write_str(
78 "a semver string like \"2.0.0\" (legacy integer `2` also accepted \
79 and auto-rewritten to \"2.0.0\" on next save)",
80 )
81 }
82 fn visit_str<E: de::Error>(self, s: &str) -> Result<SchemaVersion, E> {
83 Ok(SchemaVersion {
84 value: s.to_string(),
85 from_legacy_int: false,
86 })
87 }
88 fn visit_string<E: de::Error>(self, s: String) -> Result<SchemaVersion, E> {
89 Ok(SchemaVersion {
90 value: s,
91 from_legacy_int: false,
92 })
93 }
94 fn visit_u64<E: de::Error>(self, n: u64) -> Result<SchemaVersion, E> {
95 if n == 2 {
96 Ok(SchemaVersion {
97 value: "2.0.0".to_string(),
98 from_legacy_int: true,
99 })
100 } else {
101 Err(E::custom(format!(
102 "compose schema version must be a semver string like \"2.0.0\"; \
103 got integer {n} — only legacy `2` is auto-coerced"
104 )))
105 }
106 }
107 fn visit_i64<E: de::Error>(self, n: i64) -> Result<SchemaVersion, E> {
108 if n == 2 {
109 Ok(SchemaVersion {
110 value: "2.0.0".to_string(),
111 from_legacy_int: true,
112 })
113 } else {
114 Err(E::custom(format!(
115 "compose schema version must be a semver string like \"2.0.0\"; \
116 got integer {n} — only legacy `2` is auto-coerced"
117 )))
118 }
119 }
120 }
121 d.deserialize_any(V)
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
127pub struct Global {
128 pub version: SchemaVersion,
129
130 #[serde(default)]
131 pub broker: Broker,
132
133 #[serde(default)]
134 pub supervisor: SupervisorCfg,
135
136 #[serde(default)]
137 pub budget: Budget,
138
139 #[serde(default)]
140 pub hitl: Hitl,
141
142 #[serde(default)]
143 pub rate_limits: RateLimits,
144
145 #[serde(default)]
148 pub interfaces: Vec<Interface>,
149
150 #[serde(default)]
152 pub projects: Vec<ProjectRef>,
153
154 #[serde(default)]
158 pub attachments: Attachments,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
162pub struct Attachments {
163 #[serde(default = "default_attachments_enabled")]
164 pub enabled: bool,
165 #[serde(default = "default_attachments_max_size_bytes")]
166 pub max_size_bytes: u64,
167 #[serde(default = "default_attachments_allowed_roots")]
172 pub allowed_roots: Vec<String>,
173 #[serde(default)]
174 pub scanner: Option<AttachmentScanner>,
175 #[serde(default)]
179 pub audit_log_path: Option<PathBuf>,
180 #[serde(default = "default_attachments_tempfile_ttl_seconds")]
188 pub tempfile_ttl_seconds: u64,
189}
190
191impl Default for Attachments {
192 fn default() -> Self {
193 Self {
194 enabled: default_attachments_enabled(),
195 max_size_bytes: default_attachments_max_size_bytes(),
196 allowed_roots: default_attachments_allowed_roots(),
197 scanner: None,
198 audit_log_path: None,
199 tempfile_ttl_seconds: default_attachments_tempfile_ttl_seconds(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
205pub struct AttachmentScanner {
206 pub command: String,
209 #[serde(default = "default_scanner_timeout_seconds")]
210 pub timeout_seconds: u64,
211}
212
213fn default_attachments_enabled() -> bool {
214 true
215}
216
217fn default_attachments_max_size_bytes() -> u64 {
218 5 * 1024 * 1024
219}
220
221fn default_attachments_allowed_roots() -> Vec<String> {
222 vec!["$HOME".to_string()]
223}
224
225fn default_scanner_timeout_seconds() -> u64 {
226 30
227}
228
229fn default_attachments_tempfile_ttl_seconds() -> u64 {
230 6 * 60 * 60
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
234pub struct Interface {
235 pub r#type: String,
237 pub name: String,
239 #[serde(default)]
244 #[schemars(with = "serde_json::Value")]
245 pub config: serde_yaml::Value,
246}
247
248impl Interface {
249 pub fn is_telegram(&self) -> bool {
250 self.r#type == "telegram"
251 }
252
253 pub fn manager(&self) -> Option<String> {
255 self.config_str("manager")
256 }
257
258 pub fn bot_token_env(&self) -> Option<String> {
260 self.config_str("bot_token_env")
261 }
262
263 pub fn authorized_chat_ids_env(&self) -> Option<String> {
265 self.config_str("authorized_chat_ids_env")
266 }
267
268 fn config_str(&self, key: &str) -> Option<String> {
269 match &self.config {
270 serde_yaml::Value::Mapping(m) => m
271 .get(serde_yaml::Value::String(key.into()))
272 .and_then(|v| v.as_str())
273 .map(str::to_owned),
274 _ => None,
275 }
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
280pub struct Budget {
281 #[serde(default)]
282 pub daily_usd_limit: Option<f64>,
283 #[serde(default)]
284 pub warn_threshold_pct: Option<u32>,
285 #[serde(default)]
286 pub message_ttl_hours: Option<u32>,
287 #[serde(default)]
288 pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
289}
290
291#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
293pub struct RateLimits {
294 #[serde(default)]
296 pub default_on_hit: Vec<String>,
297
298 #[serde(default)]
300 pub hooks: Vec<RateLimitHook>,
301
302 #[serde(default = "default_fallback_wait")]
305 pub fallback_wait_seconds: u64,
306}
307
308fn default_fallback_wait() -> u64 {
309 30 * 60
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325pub struct RateLimitHook {
326 pub name: String,
327 pub action: String,
328 #[serde(default)]
329 pub to: Option<String>,
330 #[serde(default)]
331 pub template: Option<String>,
332 #[serde(default)]
333 pub url: Option<String>,
334 #[serde(default)]
335 pub url_env: Option<String>,
336 #[serde(default)]
337 pub method: Option<String>,
338 #[serde(default)]
339 pub command: Vec<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343pub struct Hitl {
344 #[serde(default = "default_sensitive_actions")]
345 pub globally_sensitive_actions: Vec<String>,
346 #[serde(default)]
347 pub auto_approve_windows: Vec<AutoApprove>,
348}
349
350impl Default for Hitl {
351 fn default() -> Self {
352 Self {
353 globally_sensitive_actions: default_sensitive_actions(),
354 auto_approve_windows: Vec::new(),
355 }
356 }
357}
358
359fn default_sensitive_actions() -> Vec<String> {
360 vec![
361 "publish".into(),
362 "release".into(),
363 "payment".into(),
364 "external_email".into(),
365 "external_api_post".into(),
366 "merge_to_main".into(),
367 "dns_change".into(),
368 "deploy".into(),
369 ]
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
373pub struct AutoApprove {
374 pub action: String,
375 #[serde(default)]
376 pub project: Option<String>,
377 #[serde(default)]
378 pub agent: Option<String>,
379 #[serde(default)]
380 pub scope: Option<String>,
381 pub until: String,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
386pub struct ProjectRef {
387 pub file: PathBuf,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
391pub struct Broker {
392 #[serde(default = "default_broker_type")]
393 pub r#type: String,
394 #[serde(default = "default_mailbox_path")]
395 pub path: PathBuf,
396}
397
398impl Default for Broker {
399 fn default() -> Self {
400 Self {
401 r#type: default_broker_type(),
402 path: default_mailbox_path(),
403 }
404 }
405}
406
407fn default_broker_type() -> String {
408 "sqlite".into()
409}
410
411fn default_mailbox_path() -> PathBuf {
412 PathBuf::from("state/mailbox.db")
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
416pub struct SupervisorCfg {
417 #[serde(default = "default_supervisor_type")]
418 pub r#type: String,
419 #[serde(default = "default_tmux_prefix")]
420 pub tmux_prefix: String,
421 #[serde(default = "default_drain_timeout_secs")]
428 pub drain_timeout_secs: u64,
429}
430
431impl Default for SupervisorCfg {
432 fn default() -> Self {
433 Self {
434 r#type: default_supervisor_type(),
435 tmux_prefix: default_tmux_prefix(),
436 drain_timeout_secs: default_drain_timeout_secs(),
437 }
438 }
439}
440
441fn default_supervisor_type() -> String {
442 "tmux".into()
443}
444
445fn default_drain_timeout_secs() -> u64 {
446 10
447}
448
449fn default_tmux_prefix() -> String {
450 "a-".into()
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
455pub struct Project {
456 pub version: u32,
457 pub project: ProjectMeta,
458
459 #[serde(default)]
460 pub channels: Vec<Channel>,
461
462 #[serde(default)]
463 pub managers: BTreeMap<String, Agent>,
464
465 #[serde(default)]
466 pub workers: BTreeMap<String, Agent>,
467
468 #[serde(default)]
479 pub interfaces: Option<ProjectInterfaces>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
483pub struct ProjectMeta {
484 pub id: String,
485 pub name: String,
486 pub cwd: PathBuf,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
490pub struct Channel {
491 pub name: String,
492 #[serde(default)]
494 pub members: ChannelMembers,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
498#[serde(untagged)]
499pub enum ChannelMembers {
500 All(String),
501 Explicit(Vec<String>),
502}
503
504impl Default for ChannelMembers {
505 fn default() -> Self {
506 Self::Explicit(Vec::new())
507 }
508}
509
510impl ChannelMembers {
511 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
512 match self {
513 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
514 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
515 _ => false,
516 }
517 }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
527#[serde(untagged)]
528pub enum RolePrompt {
529 Single(PathBuf),
530 Multiple(Vec<PathBuf>),
531}
532
533impl RolePrompt {
534 pub fn paths(&self) -> Vec<&Path> {
537 match self {
538 RolePrompt::Single(p) => vec![p.as_path()],
539 RolePrompt::Multiple(v) => v.iter().map(|p| p.as_path()).collect(),
540 }
541 }
542
543 pub fn is_blank(&self) -> bool {
548 match self {
549 RolePrompt::Single(p) => p.as_os_str().is_empty(),
550 RolePrompt::Multiple(v) => v.is_empty(),
551 }
552 }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
563pub struct HookSpec {
564 pub event: String,
566
567 #[serde(default)]
571 pub matcher: Option<String>,
572
573 pub command: PathBuf,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
588pub struct McpServer {
589 pub command: String,
592
593 #[serde(default)]
595 pub args: Vec<String>,
596
597 #[serde(default)]
603 pub env: BTreeMap<String, String>,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
607pub struct Agent {
608 #[serde(default = "default_runtime")]
609 pub runtime: String,
610 pub model: Option<String>,
611 pub role_prompt: Option<RolePrompt>,
612 #[serde(default)]
613 pub permission_mode: Option<String>,
614 #[serde(default = "default_autonomy")]
615 pub autonomy: String,
616 #[serde(default)]
617 pub can_dm: Vec<String>,
618 #[serde(default)]
619 pub can_broadcast: Vec<String>,
620 #[serde(default)]
621 pub reports_to: Option<String>,
622
623 #[serde(default)]
625 pub on_rate_limit: Option<Vec<String>>,
626
627 #[serde(default)]
633 pub effort: Option<EffortLevel>,
634
635 #[serde(default)]
649 pub ultracode: bool,
650
651 #[serde(default)]
656 pub interfaces: Option<AgentInterfaces>,
657
658 #[serde(default)]
666 pub display_name: Option<String>,
667
668 #[serde(default)]
677 pub hooks: Vec<HookSpec>,
678
679 #[serde(default)]
687 pub mcps: BTreeMap<String, McpServer>,
688
689 #[serde(default)]
702 pub subagents: Vec<PathBuf>,
703
704 #[serde(default)]
717 pub skills: Vec<PathBuf>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
723pub struct AgentInterfaces {
724 #[serde(default)]
729 pub telegram: Option<TelegramConfig>,
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
735pub struct TelegramConfig {
736 pub bot_token_env: String,
739 pub chat_ids_env: String,
742 #[serde(default)]
747 pub speech_to_text: Option<SttConfig>,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
754pub struct SttConfig {
755 pub provider: String,
757 pub api_key_env: String,
761 pub model: String,
763 #[serde(default)]
766 pub language: Option<String>,
767}
768
769impl Agent {
770 pub fn telegram(&self) -> Option<&TelegramConfig> {
774 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
775 }
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
783pub struct ProjectInterfaces {
784 #[serde(default)]
791 pub telegram: Option<ProjectTelegramConfig>,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
800pub struct ProjectTelegramConfig {
801 #[serde(default)]
806 pub manager_bot: Option<ManagerBotConfig>,
807
808 #[serde(default)]
814 pub profile_picture: Option<ProfilePictureConfig>,
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
821pub struct ManagerBotConfig {
822 pub token_env: String,
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
835pub struct ProfilePictureConfig {
836 #[serde(default)]
842 pub image_model: Option<ImageModelConfig>,
843
844 #[serde(default)]
849 pub fallback: ProfilePictureFallback,
850}
851
852#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
857#[serde(rename_all = "lowercase")]
858pub enum ProfilePictureFallback {
859 #[default]
863 Initials,
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
871pub struct ImageModelConfig {
872 pub provider: String,
874 pub api_key_env: String,
879 pub model: String,
882}
883
884impl Project {
885 pub fn telegram(&self) -> Option<&ProjectTelegramConfig> {
889 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
890 }
891}
892
893#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
897#[serde(rename_all = "lowercase")]
898pub enum EffortLevel {
899 Low,
900 Medium,
901 High,
902 Xhigh,
903 Max,
904}
905
906impl EffortLevel {
907 pub fn as_str(self) -> &'static str {
910 match self {
911 EffortLevel::Low => "low",
912 EffortLevel::Medium => "medium",
913 EffortLevel::High => "high",
914 EffortLevel::Xhigh => "xhigh",
915 EffortLevel::Max => "max",
916 }
917 }
918}
919
920fn default_runtime() -> String {
921 "claude-code".into()
922}
923
924fn default_autonomy() -> String {
925 "low_risk_only".into()
926}
927
928#[derive(Debug, Clone)]
930pub struct Compose {
931 pub root: PathBuf,
932 pub global: Global,
933 pub projects: Vec<Project>,
934}
935
936impl Compose {
937 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
948 let start = start
949 .canonicalize()
950 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
951 let mut cur: Option<&Path> = Some(&start);
952 while let Some(dir) = cur {
953 let candidate = dir.join(".team").join("team-compose.yaml");
954 if candidate.is_file() {
955 return Ok(dir.join(".team"));
956 }
957 cur = dir.parent();
958 }
959 Err(anyhow::anyhow!(
960 "no `.team/team-compose.yaml` found in {} or any parent",
961 start.display()
962 ))
963 }
964
965 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
967 let root = root.as_ref().to_path_buf();
968 let global_path = root.join("team-compose.yaml");
969 let raw = std::fs::read_to_string(&global_path)
970 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?;
971 let global: Global = serde_yaml::from_str(&raw)
972 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
973
974 if global.version.from_legacy_int {
991 if let Err(e) = rewrite_legacy_version_in_file(&global_path, &raw) {
992 tracing::warn!(
993 target: "team-core::compose",
994 "could not rewrite legacy `version: 2` in {}: {e}; \
995 proceeding with in-memory `\"2.0.0\"`",
996 global_path.display()
997 );
998 }
999 }
1000
1001 let mut projects = Vec::with_capacity(global.projects.len());
1002 for r in &global.projects {
1003 let p = root.join(&r.file);
1004 let parsed: Project = serde_yaml::from_str(
1005 &std::fs::read_to_string(&p)
1006 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
1007 )
1008 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
1009 projects.push(parsed);
1010 }
1011
1012 Ok(Self {
1013 root,
1014 global,
1015 projects,
1016 })
1017 }
1018
1019 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
1021 self.projects.iter().flat_map(|p| {
1022 p.managers
1023 .iter()
1024 .map(move |(id, a)| AgentHandle {
1025 project: &p.project.id,
1026 agent: id,
1027 spec: a,
1028 is_manager: true,
1029 })
1030 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
1031 project: &p.project.id,
1032 agent: id,
1033 spec: a,
1034 is_manager: false,
1035 }))
1036 })
1037 }
1038}
1039
1040fn rewrite_legacy_version_in_file(path: &Path, raw: &str) -> anyhow::Result<()> {
1046 let updated = crate::yaml_edit::set_top_level_scalar(raw, "version", "\"2.0.0\"")?;
1047 std::fs::write(path, updated).map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
1048 Ok(())
1049}
1050
1051#[derive(Debug, Clone, Copy)]
1052pub struct AgentHandle<'a> {
1053 pub project: &'a str,
1054 pub agent: &'a str,
1055 pub spec: &'a Agent,
1056 pub is_manager: bool,
1057}
1058
1059impl AgentHandle<'_> {
1060 pub fn id(&self) -> String {
1062 format!("{}:{}", self.project, self.agent)
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069
1070 #[test]
1071 fn channel_members_all_expands() {
1072 let all = ChannelMembers::All("*".into());
1073 assert!(all.includes("dev1", &["dev1", "dev2"]));
1074 assert!(!all.includes("ghost", &["dev1", "dev2"]));
1075 }
1076
1077 #[test]
1078 fn channel_members_explicit_checks_list() {
1079 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
1080 assert!(exp.includes("dev1", &[]));
1081 assert!(!exp.includes("dev2", &[]));
1082 }
1083
1084 #[test]
1085 fn agent_defaults_are_stable() {
1086 let a: Agent = serde_yaml::from_str("model: claude-opus-4-8\n").unwrap();
1087 assert_eq!(a.runtime, "claude-code");
1088 assert_eq!(a.autonomy, "low_risk_only");
1089 assert!(a.interfaces.is_none());
1090 assert!(a.telegram().is_none());
1091 assert!(a.effort.is_none());
1092 }
1093
1094 #[test]
1095 fn agent_telegram_block_parses_under_interfaces() {
1096 let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
1097 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1098 let tg = a.telegram().expect("telegram parsed");
1099 assert_eq!(tg.bot_token_env, "T");
1100 assert_eq!(tg.chat_ids_env, "C");
1101 assert!(tg.speech_to_text.is_none());
1102 }
1103
1104 #[test]
1105 fn agent_telegram_block_parses_speech_to_text() {
1106 let yaml = "\
1107interfaces:
1108 telegram:
1109 bot_token_env: T
1110 chat_ids_env: C
1111 speech_to_text:
1112 provider: groq
1113 api_key_env: GROQ_API_KEY
1114 model: whisper-large-v3
1115 language: en
1116";
1117 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1118 let stt = a
1119 .telegram()
1120 .and_then(|t| t.speech_to_text.as_ref())
1121 .expect("speech_to_text parsed");
1122 assert_eq!(stt.provider, "groq");
1123 assert_eq!(stt.api_key_env, "GROQ_API_KEY");
1124 assert_eq!(stt.model, "whisper-large-v3");
1125 assert_eq!(stt.language.as_deref(), Some("en"));
1126 }
1127
1128 #[test]
1129 fn agent_telegram_block_parses_speech_to_text_without_language() {
1130 let yaml = "\
1131interfaces:
1132 telegram:
1133 bot_token_env: T
1134 chat_ids_env: C
1135 speech_to_text:
1136 provider: groq
1137 api_key_env: K
1138 model: whisper-large-v3
1139";
1140 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1141 let stt = a
1142 .telegram()
1143 .and_then(|t| t.speech_to_text.as_ref())
1144 .expect("speech_to_text parsed");
1145 assert!(stt.language.is_none());
1146 }
1147
1148 #[test]
1149 fn effort_parses_all_five_levels() {
1150 for (yaml, expected) in [
1151 ("effort: low\n", EffortLevel::Low),
1152 ("effort: medium\n", EffortLevel::Medium),
1153 ("effort: high\n", EffortLevel::High),
1154 ("effort: xhigh\n", EffortLevel::Xhigh),
1155 ("effort: max\n", EffortLevel::Max),
1156 ] {
1157 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
1158 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
1159 }
1160 }
1161
1162 #[test]
1163 fn ultracode_parses_from_yaml_and_defaults_false() {
1164 let on: Agent = serde_yaml::from_str("ultracode: true\n").expect("ultracode: true");
1168 assert!(on.ultracode, "ultracode: true must parse to true");
1169 let off: Agent = serde_yaml::from_str("ultracode: false\n").expect("ultracode: false");
1170 assert!(!off.ultracode, "ultracode: false must parse to false");
1171 let absent: Agent = serde_yaml::from_str("effort: low\n").expect("no ultracode key");
1173 assert!(!absent.ultracode, "omitted ultracode must default to false");
1174 }
1175
1176 #[test]
1177 fn effort_unknown_value_is_rejected() {
1178 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
1179 .expect_err("typo'd effort value must fail to parse");
1180 let msg = err.to_string();
1181 assert!(
1182 msg.contains("low") && msg.contains("max"),
1183 "error should enumerate valid variants; got: {msg}"
1184 );
1185 }
1186
1187 #[test]
1188 fn effort_renders_to_lowercase_string() {
1189 assert_eq!(EffortLevel::Low.as_str(), "low");
1190 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
1191 assert_eq!(EffortLevel::Max.as_str(), "max");
1192 }
1193
1194 #[test]
1195 fn discover_prefers_dot_team() {
1196 let tmp = tempfile::tempdir().unwrap();
1197 let repo = tmp.path();
1198 std::fs::create_dir_all(repo.join(".team")).unwrap();
1199 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1200 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
1202
1203 let sub = repo.join("src/deep/nested");
1205 std::fs::create_dir_all(&sub).unwrap();
1206 let found = Compose::discover(&sub).unwrap();
1207 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
1208 }
1209
1210 #[test]
1211 fn discover_no_longer_falls_back_to_flat_layout() {
1212 let tmp = tempfile::tempdir().unwrap();
1216 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").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 discover_returns_first_dot_team_walking_up() {
1223 let tmp = tempfile::tempdir().unwrap();
1226 let outer = tmp.path();
1227 let inner = outer.join("packages/inner");
1228 std::fs::create_dir_all(outer.join(".team")).unwrap();
1229 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1230 std::fs::create_dir_all(inner.join(".team")).unwrap();
1231 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1232
1233 let from_inner = inner.join("src/deep");
1234 std::fs::create_dir_all(&from_inner).unwrap();
1235 let found = Compose::discover(&from_inner).unwrap();
1236 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
1237 }
1238
1239 #[test]
1240 fn discover_errors_when_nothing_found() {
1241 let tmp = tempfile::tempdir().unwrap();
1242 let err = Compose::discover(tmp.path()).unwrap_err();
1243 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1244 }
1245
1246 #[test]
1247 fn role_prompt_parses_single_string_form() {
1248 let yaml = "role_prompt: roles/mgr.md\n";
1249 let agent: Agent = serde_yaml::from_str(&format!(
1250 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1251 ))
1252 .unwrap();
1253 match agent.role_prompt.unwrap() {
1254 RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
1255 other => panic!("expected Single, got {other:?}"),
1256 }
1257 }
1258
1259 #[test]
1260 fn role_prompt_parses_list_form() {
1261 let yaml = "role_prompt:\n - roles/_base.md\n - roles/mgr.md\n";
1262 let agent: Agent = serde_yaml::from_str(&format!(
1263 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1264 ))
1265 .unwrap();
1266 match agent.role_prompt.unwrap() {
1267 RolePrompt::Multiple(v) => assert_eq!(
1268 v,
1269 vec![
1270 PathBuf::from("roles/_base.md"),
1271 PathBuf::from("roles/mgr.md"),
1272 ]
1273 ),
1274 other => panic!("expected Multiple, got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn role_prompt_paths_returns_declared_order() {
1280 let rp = RolePrompt::Multiple(vec![
1281 PathBuf::from("a.md"),
1282 PathBuf::from("b.md"),
1283 PathBuf::from("c.md"),
1284 ]);
1285 let got: Vec<&Path> = rp.paths();
1286 assert_eq!(
1287 got,
1288 vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
1289 );
1290 }
1291
1292 #[test]
1300 fn schema_version_accepts_semver_string() {
1301 let v: SchemaVersion = serde_yaml::from_str("\"2.0.0\"").unwrap();
1302 assert_eq!(v.value, "2.0.0");
1303 assert!(!v.from_legacy_int, "string form is NOT the legacy path");
1304 }
1305
1306 #[test]
1307 fn schema_version_accepts_arbitrary_semver_string_for_later_validation() {
1308 let v: SchemaVersion = serde_yaml::from_str("\"abc\"").unwrap();
1313 assert_eq!(v.value, "abc");
1314 }
1315
1316 #[test]
1317 fn schema_version_coerces_legacy_integer_two() {
1318 let v: SchemaVersion = serde_yaml::from_str("2").unwrap();
1319 assert_eq!(v.value, "2.0.0");
1320 assert!(v.from_legacy_int, "integer-2 must be flagged for rewrite");
1321 }
1322
1323 #[test]
1324 fn schema_version_rejects_other_integers() {
1325 for n in [0u64, 1, 3, 99] {
1329 let err = serde_yaml::from_str::<SchemaVersion>(&n.to_string())
1330 .expect_err("non-2 integer must fail");
1331 let msg = err.to_string();
1332 assert!(
1333 msg.contains("only legacy `2` is auto-coerced"),
1334 "error must name the constraint; got: {msg}"
1335 );
1336 }
1337 }
1338
1339 #[test]
1340 fn schema_version_rejects_non_string_non_int_shapes() {
1341 for yaml in ["true", "[1,2,3]", "{a: b}"] {
1343 let res = serde_yaml::from_str::<SchemaVersion>(yaml);
1344 assert!(res.is_err(), "yaml `{yaml}` must fail to deserialize");
1345 }
1346 }
1347
1348 #[test]
1355 fn load_rewrites_legacy_version_two_in_file_and_in_memory() {
1356 let tmp = tempfile::tempdir().unwrap();
1357 let root = tmp.path().join(".team");
1358 std::fs::create_dir_all(&root).unwrap();
1359 let yaml = "\
1360# T-265 fixture — legacy version
1361version: 2
1362broker:
1363 type: sqlite
1364 path: state/mailbox.db
1365";
1366 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1367 let compose = Compose::load(&root).expect("load succeeds on legacy file");
1368 assert_eq!(compose.global.version.value, "2.0.0");
1370 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1372 assert!(
1373 after.contains("version: \"2.0.0\""),
1374 "file must be rewritten;\n{after}"
1375 );
1376 assert!(
1377 !after.contains("\nversion: 2\n"),
1378 "no legacy literal must survive;\n{after}"
1379 );
1380 assert!(
1382 after.contains("# T-265 fixture"),
1383 "comment must survive the rewrite;\n{after}"
1384 );
1385 assert!(after.contains("type: sqlite"));
1387 }
1388
1389 #[test]
1390 fn load_leaves_canonical_semver_file_untouched() {
1391 let tmp = tempfile::tempdir().unwrap();
1392 let root = tmp.path().join(".team");
1393 std::fs::create_dir_all(&root).unwrap();
1394 let yaml = "\
1395version: \"2.0.0\"
1396broker:
1397 type: sqlite
1398";
1399 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1400 let compose = Compose::load(&root).expect("load succeeds");
1401 assert_eq!(compose.global.version.value, "2.0.0");
1402 assert!(
1403 !compose.global.version.from_legacy_int,
1404 "canonical file must NOT be flagged for rewrite"
1405 );
1406 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1408 assert_eq!(after, yaml, "canonical file must NOT be mutated on load");
1409 }
1410
1411 #[test]
1412 fn load_hard_errors_on_non_two_integer_version() {
1413 let tmp = tempfile::tempdir().unwrap();
1414 let root = tmp.path().join(".team");
1415 std::fs::create_dir_all(&root).unwrap();
1416 std::fs::write(root.join("team-compose.yaml"), "version: 3\n").unwrap();
1417 let err = Compose::load(&root).expect_err("must reject integer-3 at parse");
1418 assert!(
1419 err.to_string().contains("only legacy `2` is auto-coerced"),
1420 "error must name the constraint; got: {err}"
1421 );
1422 }
1423
1424 #[cfg(unix)]
1441 #[test]
1442 fn load_degrades_to_in_memory_when_legacy_rewrite_cannot_write() {
1443 use std::io::Write;
1444 use std::os::unix::fs::PermissionsExt;
1445 use std::sync::{Arc, Mutex};
1446
1447 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1450 impl Write for CaptureWriter {
1451 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1452 self.0.lock().unwrap().extend_from_slice(buf);
1453 Ok(buf.len())
1454 }
1455 fn flush(&mut self) -> std::io::Result<()> {
1456 Ok(())
1457 }
1458 }
1459
1460 let tmp = tempfile::tempdir().unwrap();
1461 let root = tmp.path().join(".team");
1462 std::fs::create_dir_all(&root).unwrap();
1463 let compose_path = root.join("team-compose.yaml");
1464 std::fs::write(
1465 &compose_path,
1466 "version: 2\nbroker:\n type: sqlite\n path: state/mailbox.db\n",
1467 )
1468 .unwrap();
1469
1470 std::fs::set_permissions(&compose_path, std::fs::Permissions::from_mode(0o444)).unwrap();
1473
1474 let logs = Arc::new(Mutex::new(Vec::<u8>::new()));
1477 let sink = logs.clone();
1478 let subscriber = tracing_subscriber::fmt()
1479 .with_max_level(tracing::Level::WARN)
1480 .with_writer(move || CaptureWriter(sink.clone()))
1481 .finish();
1482 let result = tracing::subscriber::with_default(subscriber, || Compose::load(&root));
1483
1484 std::fs::set_permissions(&compose_path, std::fs::Permissions::from_mode(0o644)).unwrap();
1487
1488 let compose = result.expect("a read-only compose file must degrade, not error");
1490 assert_eq!(compose.global.version.value, "2.0.0");
1492 assert!(
1493 compose.global.version.from_legacy_int,
1494 "legacy-int normalization must still be flagged in memory"
1495 );
1496 let captured = String::from_utf8(logs.lock().unwrap().clone()).unwrap();
1498 assert!(
1499 captured.contains("could not rewrite legacy"),
1500 "the read-only degrade must emit its tracing::warn; captured:\n{captured}"
1501 );
1502 let after = std::fs::read_to_string(&compose_path).unwrap();
1505 assert!(
1506 after.contains("version: 2"),
1507 "the file must be left un-rewritten on the degrade path;\n{after}"
1508 );
1509 }
1510
1511 const PROJECT_YAML_HEAD: &str = "\
1518version: 2
1519project:
1520 id: p
1521 name: P
1522 cwd: .
1523";
1524
1525 #[test]
1526 fn project_without_interfaces_block_parses_unchanged() {
1527 let p: Project = serde_yaml::from_str(PROJECT_YAML_HEAD).unwrap();
1530 assert!(p.interfaces.is_none());
1531 assert!(p.telegram().is_none());
1532 }
1533
1534 #[test]
1535 fn project_telegram_block_parses_under_interfaces() {
1536 let yaml = format!(
1540 "{PROJECT_YAML_HEAD}\
1541interfaces:
1542 telegram:
1543 manager_bot:
1544 token_env: TEAMCTL_TG_MANAGER_TOKEN
1545 profile_picture:
1546 image_model:
1547 provider: openai
1548 api_key_env: OPENAI_API_KEY
1549 model: gpt-image-2
1550 fallback: initials
1551"
1552 );
1553 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1554 let tg = p.telegram().expect("project telegram parsed");
1555 let mb = tg.manager_bot.as_ref().expect("manager_bot parsed");
1556 assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
1557 let pp = tg.profile_picture.as_ref().expect("profile_picture parsed");
1558 let im = pp.image_model.as_ref().expect("image_model parsed");
1559 assert_eq!(im.provider, "openai");
1560 assert_eq!(im.api_key_env, "OPENAI_API_KEY");
1561 assert_eq!(im.model, "gpt-image-2");
1562 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1563 }
1564
1565 #[test]
1566 fn project_telegram_block_parses_manager_bot_only() {
1567 let yaml = format!(
1572 "{PROJECT_YAML_HEAD}\
1573interfaces:
1574 telegram:
1575 manager_bot:
1576 token_env: TEAMCTL_TG_MANAGER_TOKEN
1577"
1578 );
1579 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1580 let tg = p.telegram().expect("project telegram parsed");
1581 assert_eq!(
1582 tg.manager_bot.as_ref().unwrap().token_env,
1583 "TEAMCTL_TG_MANAGER_TOKEN"
1584 );
1585 assert!(tg.profile_picture.is_none());
1586 }
1587
1588 #[test]
1589 fn profile_picture_fallback_defaults_to_initials_when_omitted() {
1590 let yaml = format!(
1594 "{PROJECT_YAML_HEAD}\
1595interfaces:
1596 telegram:
1597 profile_picture:
1598 image_model:
1599 provider: openai
1600 api_key_env: OPENAI_API_KEY
1601 model: gpt-image-2
1602"
1603 );
1604 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1605 let pp = p
1606 .telegram()
1607 .and_then(|t| t.profile_picture.as_ref())
1608 .expect("profile_picture parsed");
1609 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1610 }
1611
1612 #[test]
1613 fn profile_picture_image_model_optional_with_initials_fallback() {
1614 let yaml = format!(
1618 "{PROJECT_YAML_HEAD}\
1619interfaces:
1620 telegram:
1621 profile_picture:
1622 fallback: initials
1623"
1624 );
1625 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1626 let pp = p
1627 .telegram()
1628 .and_then(|t| t.profile_picture.as_ref())
1629 .expect("profile_picture parsed");
1630 assert!(pp.image_model.is_none());
1631 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1632 }
1633
1634 #[test]
1635 fn manager_bot_missing_token_env_rejected() {
1636 let yaml = format!(
1640 "{PROJECT_YAML_HEAD}\
1641interfaces:
1642 telegram:
1643 manager_bot: {{}}
1644"
1645 );
1646 let err =
1647 serde_yaml::from_str::<Project>(&yaml).expect_err("malformed manager_bot must reject");
1648 assert!(
1649 err.to_string().contains("token_env"),
1650 "error must name the missing field: {err}"
1651 );
1652 }
1653
1654 #[test]
1655 fn image_model_missing_required_fields_rejected() {
1656 for (label, yaml_fragment) in [
1660 ("missing provider", "api_key_env: K\n model: M"),
1661 ("missing api_key_env", "provider: openai\n model: M"),
1662 ("missing model", "provider: openai\n api_key_env: K"),
1663 ] {
1664 let yaml = format!(
1665 "{PROJECT_YAML_HEAD}\
1666interfaces:
1667 telegram:
1668 profile_picture:
1669 image_model:
1670 {yaml_fragment}
1671"
1672 );
1673 let result = serde_yaml::from_str::<Project>(&yaml);
1674 assert!(
1675 result.is_err(),
1676 "malformed image_model ({label}) must reject, got: {result:?}"
1677 );
1678 }
1679 }
1680
1681 #[test]
1682 fn profile_picture_fallback_unknown_value_rejected() {
1683 let yaml = format!(
1687 "{PROJECT_YAML_HEAD}\
1688interfaces:
1689 telegram:
1690 profile_picture:
1691 fallback: emoji
1692"
1693 );
1694 let err = serde_yaml::from_str::<Project>(&yaml)
1695 .expect_err("unknown fallback variant must reject");
1696 assert!(
1697 err.to_string().contains("emoji") || err.to_string().contains("variant"),
1698 "error must explain the unknown variant: {err}"
1699 );
1700 }
1701}