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)]
533pub struct Agent {
534 #[serde(default = "default_runtime")]
535 pub runtime: String,
536 pub model: Option<String>,
537 pub role_prompt: Option<RolePrompt>,
538 #[serde(default)]
539 pub permission_mode: Option<String>,
540 #[serde(default = "default_autonomy")]
541 pub autonomy: String,
542 #[serde(default)]
543 pub can_dm: Vec<String>,
544 #[serde(default)]
545 pub can_broadcast: Vec<String>,
546 #[serde(default)]
547 pub reports_to: Option<String>,
548
549 #[serde(default)]
551 pub on_rate_limit: Option<Vec<String>>,
552
553 #[serde(default)]
559 pub effort: Option<EffortLevel>,
560
561 #[serde(default)]
566 pub interfaces: Option<AgentInterfaces>,
567
568 #[serde(default)]
576 pub display_name: Option<String>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, Default)]
582pub struct AgentInterfaces {
583 #[serde(default)]
588 pub telegram: Option<TelegramConfig>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct TelegramConfig {
595 pub bot_token_env: String,
598 pub chat_ids_env: String,
601 #[serde(default)]
606 pub speech_to_text: Option<SttConfig>,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct SttConfig {
614 pub provider: String,
616 pub api_key_env: String,
620 pub model: String,
622 #[serde(default)]
625 pub language: Option<String>,
626}
627
628impl Agent {
629 pub fn telegram(&self) -> Option<&TelegramConfig> {
633 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
634 }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, Default)]
642pub struct ProjectInterfaces {
643 #[serde(default)]
650 pub telegram: Option<ProjectTelegramConfig>,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize, Default)]
659pub struct ProjectTelegramConfig {
660 #[serde(default)]
665 pub manager_bot: Option<ManagerBotConfig>,
666
667 #[serde(default)]
673 pub profile_picture: Option<ProfilePictureConfig>,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct ManagerBotConfig {
681 pub token_env: String,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, Default)]
694pub struct ProfilePictureConfig {
695 #[serde(default)]
701 pub image_model: Option<ImageModelConfig>,
702
703 #[serde(default)]
708 pub fallback: ProfilePictureFallback,
709}
710
711#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
716#[serde(rename_all = "lowercase")]
717pub enum ProfilePictureFallback {
718 #[default]
722 Initials,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct ImageModelConfig {
731 pub provider: String,
733 pub api_key_env: String,
738 pub model: String,
741}
742
743impl Project {
744 pub fn telegram(&self) -> Option<&ProjectTelegramConfig> {
748 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
749 }
750}
751
752#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
756#[serde(rename_all = "lowercase")]
757pub enum EffortLevel {
758 Low,
759 Medium,
760 High,
761 Xhigh,
762 Max,
763}
764
765impl EffortLevel {
766 pub fn as_str(self) -> &'static str {
769 match self {
770 EffortLevel::Low => "low",
771 EffortLevel::Medium => "medium",
772 EffortLevel::High => "high",
773 EffortLevel::Xhigh => "xhigh",
774 EffortLevel::Max => "max",
775 }
776 }
777}
778
779fn default_runtime() -> String {
780 "claude-code".into()
781}
782
783fn default_autonomy() -> String {
784 "low_risk_only".into()
785}
786
787#[derive(Debug, Clone)]
789pub struct Compose {
790 pub root: PathBuf,
791 pub global: Global,
792 pub projects: Vec<Project>,
793}
794
795impl Compose {
796 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
807 let start = start
808 .canonicalize()
809 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
810 let mut cur: Option<&Path> = Some(&start);
811 while let Some(dir) = cur {
812 let candidate = dir.join(".team").join("team-compose.yaml");
813 if candidate.is_file() {
814 return Ok(dir.join(".team"));
815 }
816 cur = dir.parent();
817 }
818 Err(anyhow::anyhow!(
819 "no `.team/team-compose.yaml` found in {} or any parent",
820 start.display()
821 ))
822 }
823
824 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
826 let root = root.as_ref().to_path_buf();
827 let global_path = root.join("team-compose.yaml");
828 let raw = std::fs::read_to_string(&global_path)
829 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?;
830 let global: Global = serde_yaml::from_str(&raw)
831 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
832
833 if global.version.from_legacy_int {
850 if let Err(e) = rewrite_legacy_version_in_file(&global_path, &raw) {
851 tracing::warn!(
852 target: "team-core::compose",
853 "could not rewrite legacy `version: 2` in {}: {e}; \
854 proceeding with in-memory `\"2.0.0\"`",
855 global_path.display()
856 );
857 }
858 }
859
860 let mut projects = Vec::with_capacity(global.projects.len());
861 for r in &global.projects {
862 let p = root.join(&r.file);
863 let parsed: Project = serde_yaml::from_str(
864 &std::fs::read_to_string(&p)
865 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
866 )
867 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
868 projects.push(parsed);
869 }
870
871 Ok(Self {
872 root,
873 global,
874 projects,
875 })
876 }
877
878 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
880 self.projects.iter().flat_map(|p| {
881 p.managers
882 .iter()
883 .map(move |(id, a)| AgentHandle {
884 project: &p.project.id,
885 agent: id,
886 spec: a,
887 is_manager: true,
888 })
889 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
890 project: &p.project.id,
891 agent: id,
892 spec: a,
893 is_manager: false,
894 }))
895 })
896 }
897}
898
899fn rewrite_legacy_version_in_file(path: &Path, raw: &str) -> anyhow::Result<()> {
905 let updated = crate::yaml_edit::set_top_level_scalar(raw, "version", "\"2.0.0\"")?;
906 std::fs::write(path, updated).map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
907 Ok(())
908}
909
910#[derive(Debug, Clone, Copy)]
911pub struct AgentHandle<'a> {
912 pub project: &'a str,
913 pub agent: &'a str,
914 pub spec: &'a Agent,
915 pub is_manager: bool,
916}
917
918impl AgentHandle<'_> {
919 pub fn id(&self) -> String {
921 format!("{}:{}", self.project, self.agent)
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 #[test]
930 fn channel_members_all_expands() {
931 let all = ChannelMembers::All("*".into());
932 assert!(all.includes("dev1", &["dev1", "dev2"]));
933 assert!(!all.includes("ghost", &["dev1", "dev2"]));
934 }
935
936 #[test]
937 fn channel_members_explicit_checks_list() {
938 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
939 assert!(exp.includes("dev1", &[]));
940 assert!(!exp.includes("dev2", &[]));
941 }
942
943 #[test]
944 fn agent_defaults_are_stable() {
945 let a: Agent = serde_yaml::from_str("model: claude-opus-4-8\n").unwrap();
946 assert_eq!(a.runtime, "claude-code");
947 assert_eq!(a.autonomy, "low_risk_only");
948 assert!(a.interfaces.is_none());
949 assert!(a.telegram().is_none());
950 assert!(a.effort.is_none());
951 }
952
953 #[test]
954 fn agent_telegram_block_parses_under_interfaces() {
955 let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
956 let a: Agent = serde_yaml::from_str(yaml).unwrap();
957 let tg = a.telegram().expect("telegram parsed");
958 assert_eq!(tg.bot_token_env, "T");
959 assert_eq!(tg.chat_ids_env, "C");
960 assert!(tg.speech_to_text.is_none());
961 }
962
963 #[test]
964 fn agent_telegram_block_parses_speech_to_text() {
965 let yaml = "\
966interfaces:
967 telegram:
968 bot_token_env: T
969 chat_ids_env: C
970 speech_to_text:
971 provider: groq
972 api_key_env: GROQ_API_KEY
973 model: whisper-large-v3
974 language: en
975";
976 let a: Agent = serde_yaml::from_str(yaml).unwrap();
977 let stt = a
978 .telegram()
979 .and_then(|t| t.speech_to_text.as_ref())
980 .expect("speech_to_text parsed");
981 assert_eq!(stt.provider, "groq");
982 assert_eq!(stt.api_key_env, "GROQ_API_KEY");
983 assert_eq!(stt.model, "whisper-large-v3");
984 assert_eq!(stt.language.as_deref(), Some("en"));
985 }
986
987 #[test]
988 fn agent_telegram_block_parses_speech_to_text_without_language() {
989 let yaml = "\
990interfaces:
991 telegram:
992 bot_token_env: T
993 chat_ids_env: C
994 speech_to_text:
995 provider: groq
996 api_key_env: K
997 model: whisper-large-v3
998";
999 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1000 let stt = a
1001 .telegram()
1002 .and_then(|t| t.speech_to_text.as_ref())
1003 .expect("speech_to_text parsed");
1004 assert!(stt.language.is_none());
1005 }
1006
1007 #[test]
1008 fn effort_parses_all_five_levels() {
1009 for (yaml, expected) in [
1010 ("effort: low\n", EffortLevel::Low),
1011 ("effort: medium\n", EffortLevel::Medium),
1012 ("effort: high\n", EffortLevel::High),
1013 ("effort: xhigh\n", EffortLevel::Xhigh),
1014 ("effort: max\n", EffortLevel::Max),
1015 ] {
1016 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
1017 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
1018 }
1019 }
1020
1021 #[test]
1022 fn effort_unknown_value_is_rejected() {
1023 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
1024 .expect_err("typo'd effort value must fail to parse");
1025 let msg = err.to_string();
1026 assert!(
1027 msg.contains("low") && msg.contains("max"),
1028 "error should enumerate valid variants; got: {msg}"
1029 );
1030 }
1031
1032 #[test]
1033 fn effort_renders_to_lowercase_string() {
1034 assert_eq!(EffortLevel::Low.as_str(), "low");
1035 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
1036 assert_eq!(EffortLevel::Max.as_str(), "max");
1037 }
1038
1039 #[test]
1040 fn discover_prefers_dot_team() {
1041 let tmp = tempfile::tempdir().unwrap();
1042 let repo = tmp.path();
1043 std::fs::create_dir_all(repo.join(".team")).unwrap();
1044 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1045 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
1047
1048 let sub = repo.join("src/deep/nested");
1050 std::fs::create_dir_all(&sub).unwrap();
1051 let found = Compose::discover(&sub).unwrap();
1052 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
1053 }
1054
1055 #[test]
1056 fn discover_no_longer_falls_back_to_flat_layout() {
1057 let tmp = tempfile::tempdir().unwrap();
1061 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
1062 let err = Compose::discover(tmp.path()).unwrap_err();
1063 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1064 }
1065
1066 #[test]
1067 fn discover_returns_first_dot_team_walking_up() {
1068 let tmp = tempfile::tempdir().unwrap();
1071 let outer = tmp.path();
1072 let inner = outer.join("packages/inner");
1073 std::fs::create_dir_all(outer.join(".team")).unwrap();
1074 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1075 std::fs::create_dir_all(inner.join(".team")).unwrap();
1076 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1077
1078 let from_inner = inner.join("src/deep");
1079 std::fs::create_dir_all(&from_inner).unwrap();
1080 let found = Compose::discover(&from_inner).unwrap();
1081 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
1082 }
1083
1084 #[test]
1085 fn discover_errors_when_nothing_found() {
1086 let tmp = tempfile::tempdir().unwrap();
1087 let err = Compose::discover(tmp.path()).unwrap_err();
1088 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1089 }
1090
1091 #[test]
1092 fn role_prompt_parses_single_string_form() {
1093 let yaml = "role_prompt: roles/mgr.md\n";
1094 let agent: Agent = serde_yaml::from_str(&format!(
1095 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1096 ))
1097 .unwrap();
1098 match agent.role_prompt.unwrap() {
1099 RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
1100 other => panic!("expected Single, got {other:?}"),
1101 }
1102 }
1103
1104 #[test]
1105 fn role_prompt_parses_list_form() {
1106 let yaml = "role_prompt:\n - roles/_base.md\n - roles/mgr.md\n";
1107 let agent: Agent = serde_yaml::from_str(&format!(
1108 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1109 ))
1110 .unwrap();
1111 match agent.role_prompt.unwrap() {
1112 RolePrompt::Multiple(v) => assert_eq!(
1113 v,
1114 vec![
1115 PathBuf::from("roles/_base.md"),
1116 PathBuf::from("roles/mgr.md"),
1117 ]
1118 ),
1119 other => panic!("expected Multiple, got {other:?}"),
1120 }
1121 }
1122
1123 #[test]
1124 fn role_prompt_paths_returns_declared_order() {
1125 let rp = RolePrompt::Multiple(vec![
1126 PathBuf::from("a.md"),
1127 PathBuf::from("b.md"),
1128 PathBuf::from("c.md"),
1129 ]);
1130 let got: Vec<&Path> = rp.paths();
1131 assert_eq!(
1132 got,
1133 vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
1134 );
1135 }
1136
1137 #[test]
1145 fn schema_version_accepts_semver_string() {
1146 let v: SchemaVersion = serde_yaml::from_str("\"2.0.0\"").unwrap();
1147 assert_eq!(v.value, "2.0.0");
1148 assert!(!v.from_legacy_int, "string form is NOT the legacy path");
1149 }
1150
1151 #[test]
1152 fn schema_version_accepts_arbitrary_semver_string_for_later_validation() {
1153 let v: SchemaVersion = serde_yaml::from_str("\"abc\"").unwrap();
1158 assert_eq!(v.value, "abc");
1159 }
1160
1161 #[test]
1162 fn schema_version_coerces_legacy_integer_two() {
1163 let v: SchemaVersion = serde_yaml::from_str("2").unwrap();
1164 assert_eq!(v.value, "2.0.0");
1165 assert!(v.from_legacy_int, "integer-2 must be flagged for rewrite");
1166 }
1167
1168 #[test]
1169 fn schema_version_rejects_other_integers() {
1170 for n in [0u64, 1, 3, 99] {
1174 let err = serde_yaml::from_str::<SchemaVersion>(&n.to_string())
1175 .expect_err("non-2 integer must fail");
1176 let msg = err.to_string();
1177 assert!(
1178 msg.contains("only legacy `2` is auto-coerced"),
1179 "error must name the constraint; got: {msg}"
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn schema_version_rejects_non_string_non_int_shapes() {
1186 for yaml in ["true", "[1,2,3]", "{a: b}"] {
1188 let res = serde_yaml::from_str::<SchemaVersion>(yaml);
1189 assert!(res.is_err(), "yaml `{yaml}` must fail to deserialize");
1190 }
1191 }
1192
1193 #[test]
1200 fn load_rewrites_legacy_version_two_in_file_and_in_memory() {
1201 let tmp = tempfile::tempdir().unwrap();
1202 let root = tmp.path().join(".team");
1203 std::fs::create_dir_all(&root).unwrap();
1204 let yaml = "\
1205# T-265 fixture — legacy version
1206version: 2
1207broker:
1208 type: sqlite
1209 path: state/mailbox.db
1210";
1211 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1212 let compose = Compose::load(&root).expect("load succeeds on legacy file");
1213 assert_eq!(compose.global.version.value, "2.0.0");
1215 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1217 assert!(
1218 after.contains("version: \"2.0.0\""),
1219 "file must be rewritten;\n{after}"
1220 );
1221 assert!(
1222 !after.contains("\nversion: 2\n"),
1223 "no legacy literal must survive;\n{after}"
1224 );
1225 assert!(
1227 after.contains("# T-265 fixture"),
1228 "comment must survive the rewrite;\n{after}"
1229 );
1230 assert!(after.contains("type: sqlite"));
1232 }
1233
1234 #[test]
1235 fn load_leaves_canonical_semver_file_untouched() {
1236 let tmp = tempfile::tempdir().unwrap();
1237 let root = tmp.path().join(".team");
1238 std::fs::create_dir_all(&root).unwrap();
1239 let yaml = "\
1240version: \"2.0.0\"
1241broker:
1242 type: sqlite
1243";
1244 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1245 let compose = Compose::load(&root).expect("load succeeds");
1246 assert_eq!(compose.global.version.value, "2.0.0");
1247 assert!(
1248 !compose.global.version.from_legacy_int,
1249 "canonical file must NOT be flagged for rewrite"
1250 );
1251 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1253 assert_eq!(after, yaml, "canonical file must NOT be mutated on load");
1254 }
1255
1256 #[test]
1257 fn load_hard_errors_on_non_two_integer_version() {
1258 let tmp = tempfile::tempdir().unwrap();
1259 let root = tmp.path().join(".team");
1260 std::fs::create_dir_all(&root).unwrap();
1261 std::fs::write(root.join("team-compose.yaml"), "version: 3\n").unwrap();
1262 let err = Compose::load(&root).expect_err("must reject integer-3 at parse");
1263 assert!(
1264 err.to_string().contains("only legacy `2` is auto-coerced"),
1265 "error must name the constraint; got: {err}"
1266 );
1267 }
1268
1269 const PROJECT_YAML_HEAD: &str = "\
1276version: 2
1277project:
1278 id: p
1279 name: P
1280 cwd: .
1281";
1282
1283 #[test]
1284 fn project_without_interfaces_block_parses_unchanged() {
1285 let p: Project = serde_yaml::from_str(PROJECT_YAML_HEAD).unwrap();
1288 assert!(p.interfaces.is_none());
1289 assert!(p.telegram().is_none());
1290 }
1291
1292 #[test]
1293 fn project_telegram_block_parses_under_interfaces() {
1294 let yaml = format!(
1298 "{PROJECT_YAML_HEAD}\
1299interfaces:
1300 telegram:
1301 manager_bot:
1302 token_env: TEAMCTL_TG_MANAGER_TOKEN
1303 profile_picture:
1304 image_model:
1305 provider: openai
1306 api_key_env: OPENAI_API_KEY
1307 model: gpt-image-2
1308 fallback: initials
1309"
1310 );
1311 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1312 let tg = p.telegram().expect("project telegram parsed");
1313 let mb = tg.manager_bot.as_ref().expect("manager_bot parsed");
1314 assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
1315 let pp = tg.profile_picture.as_ref().expect("profile_picture parsed");
1316 let im = pp.image_model.as_ref().expect("image_model parsed");
1317 assert_eq!(im.provider, "openai");
1318 assert_eq!(im.api_key_env, "OPENAI_API_KEY");
1319 assert_eq!(im.model, "gpt-image-2");
1320 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1321 }
1322
1323 #[test]
1324 fn project_telegram_block_parses_manager_bot_only() {
1325 let yaml = format!(
1330 "{PROJECT_YAML_HEAD}\
1331interfaces:
1332 telegram:
1333 manager_bot:
1334 token_env: TEAMCTL_TG_MANAGER_TOKEN
1335"
1336 );
1337 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1338 let tg = p.telegram().expect("project telegram parsed");
1339 assert_eq!(
1340 tg.manager_bot.as_ref().unwrap().token_env,
1341 "TEAMCTL_TG_MANAGER_TOKEN"
1342 );
1343 assert!(tg.profile_picture.is_none());
1344 }
1345
1346 #[test]
1347 fn profile_picture_fallback_defaults_to_initials_when_omitted() {
1348 let yaml = format!(
1352 "{PROJECT_YAML_HEAD}\
1353interfaces:
1354 telegram:
1355 profile_picture:
1356 image_model:
1357 provider: openai
1358 api_key_env: OPENAI_API_KEY
1359 model: gpt-image-2
1360"
1361 );
1362 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1363 let pp = p
1364 .telegram()
1365 .and_then(|t| t.profile_picture.as_ref())
1366 .expect("profile_picture parsed");
1367 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1368 }
1369
1370 #[test]
1371 fn profile_picture_image_model_optional_with_initials_fallback() {
1372 let yaml = format!(
1376 "{PROJECT_YAML_HEAD}\
1377interfaces:
1378 telegram:
1379 profile_picture:
1380 fallback: initials
1381"
1382 );
1383 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1384 let pp = p
1385 .telegram()
1386 .and_then(|t| t.profile_picture.as_ref())
1387 .expect("profile_picture parsed");
1388 assert!(pp.image_model.is_none());
1389 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1390 }
1391
1392 #[test]
1393 fn manager_bot_missing_token_env_rejected() {
1394 let yaml = format!(
1398 "{PROJECT_YAML_HEAD}\
1399interfaces:
1400 telegram:
1401 manager_bot: {{}}
1402"
1403 );
1404 let err =
1405 serde_yaml::from_str::<Project>(&yaml).expect_err("malformed manager_bot must reject");
1406 assert!(
1407 err.to_string().contains("token_env"),
1408 "error must name the missing field: {err}"
1409 );
1410 }
1411
1412 #[test]
1413 fn image_model_missing_required_fields_rejected() {
1414 for (label, yaml_fragment) in [
1418 ("missing provider", "api_key_env: K\n model: M"),
1419 ("missing api_key_env", "provider: openai\n model: M"),
1420 ("missing model", "provider: openai\n api_key_env: K"),
1421 ] {
1422 let yaml = format!(
1423 "{PROJECT_YAML_HEAD}\
1424interfaces:
1425 telegram:
1426 profile_picture:
1427 image_model:
1428 {yaml_fragment}
1429"
1430 );
1431 let result = serde_yaml::from_str::<Project>(&yaml);
1432 assert!(
1433 result.is_err(),
1434 "malformed image_model ({label}) must reject, got: {result:?}"
1435 );
1436 }
1437 }
1438
1439 #[test]
1440 fn profile_picture_fallback_unknown_value_rejected() {
1441 let yaml = format!(
1445 "{PROJECT_YAML_HEAD}\
1446interfaces:
1447 telegram:
1448 profile_picture:
1449 fallback: emoji
1450"
1451 );
1452 let err = serde_yaml::from_str::<Project>(&yaml)
1453 .expect_err("unknown fallback variant must reject");
1454 assert!(
1455 err.to_string().contains("emoji") || err.to_string().contains("variant"),
1456 "error must explain the unknown variant: {err}"
1457 );
1458 }
1459}