Skip to main content

team_core/
validate.rs

1//! Invariant checks for a loaded [`Compose`] tree.
2//!
3//! Errors are collected rather than returned on first failure so the CLI can
4//! pretty-print the full list.
5
6use std::collections::BTreeSet;
7
8use thiserror::Error;
9
10use crate::compose::{ChannelMembers, Compose};
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum ValidationError {
14    #[error("project `{0}`: duplicate agent id `{1}` in managers and workers")]
15    DuplicateAgent(String, String),
16
17    #[error(
18        "project `{project}`: unknown agent `{agent}` referenced in channel `{channel}` members"
19    )]
20    ChannelUnknownMember {
21        project: String,
22        channel: String,
23        agent: String,
24    },
25
26    #[error("project `{project}`: agent `{agent}` `can_dm` lists unknown agent `{target}`")]
27    DmUnknownTarget {
28        project: String,
29        agent: String,
30        target: String,
31    },
32
33    #[error(
34        "project `{project}`: agent `{agent}` `can_broadcast` lists unknown channel `{channel}`"
35    )]
36    BroadcastUnknownChannel {
37        project: String,
38        agent: String,
39        channel: String,
40    },
41
42    #[error(
43        "project `{project}`: agent `{agent}` has an `interfaces.telegram` block but is not a manager"
44    )]
45    TelegramInboxOnWorker { project: String, agent: String },
46
47    #[error(
48        "worker `{project}:{agent}` declares `reports_to: {target}` but no such manager exists"
49    )]
50    UnknownManager {
51        project: String,
52        agent: String,
53        target: String,
54    },
55
56    #[error("broker type `{0}` not supported (known: sqlite)")]
57    UnknownBroker(String),
58
59    #[error("supervisor type `{0}` not supported (known: tmux, systemd, launchd)")]
60    UnknownSupervisor(String),
61
62    #[error("duplicate project id `{0}`")]
63    DuplicateProject(String),
64
65    #[error(
66        "project id `{0}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
67    )]
68    InvalidProjectId(String),
69
70    #[error(
71        "project `{project}`: agent id `{agent}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
72    )]
73    InvalidAgentId { project: String, agent: String },
74
75    #[error("project `{project}`: agent `{agent}` uses runtime `{runtime}`, which is not built in and not declared in `<root>/runtimes/{runtime}.yaml`")]
76    UnknownRuntime {
77        project: String,
78        agent: String,
79        runtime: String,
80    },
81
82    #[error("supervisor.drain_timeout_secs={0} is unreasonable; expected 0..=600")]
83    DrainTimeoutOutOfRange(u64),
84
85    #[error(
86        "compose schema `version: {got}` is not a valid semver string (expected e.g. `\"2.0.0\"`)"
87    )]
88    SchemaVersionInvalid { got: String },
89
90    #[error(
91        "project `{project}`: agent `{agent}` has a blank `role_prompt` (empty string or empty list)"
92    )]
93    BlankRolePrompt { project: String, agent: String },
94
95    #[error("project `{project}`: agent `{agent}` has a blank `display_name`")]
96    BlankDisplayName { project: String, agent: String },
97
98    #[error("project `{project}`: agent `{agent}` `display_name` is {got} chars (max {max})")]
99    DisplayNameTooLong {
100        project: String,
101        agent: String,
102        got: usize,
103        max: usize,
104    },
105
106    #[error(
107        "project `{project}`: agent `{agent}` declares an MCP server named `team`, which is reserved for the built-in mailbox server"
108    )]
109    ReservedMcpServerName { project: String, agent: String },
110}
111
112/// T-160: max length for `display_name`. 64 is a sensible upper bound
113/// matching ratatui column widths in the TUI roster pane; longer names
114/// would force unsightly truncation downstream. Counted in Unicode
115/// scalar values (`chars().count()`), not grapheme clusters — most
116/// operator-typed labels are simple text where the two coincide, and
117/// the saved dependency on `unicode-segmentation` is not worth the
118/// fidelity gain for an at-most-64-cell rendering window.
119pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
120
121/// T-310: charset rule for `project.id` and agent ids — both flow,
122/// unquoted, into shell-bound strings (`{project}:{agent}` in
123/// `supervisor::build_up_command`) and into tmux session names. A
124/// conservative ASCII allowlist keeps every downstream consumer safe
125/// at the boundary instead of forcing each call site to quote
126/// defensively. `:` is intentionally NOT allowed — it's reserved as
127/// the canonical `<project>:<agent>` join separator everywhere in
128/// teamctl, and admitting it into an id would make `id()` parse
129/// ambiguous.
130///
131/// Rejected by construction: any whitespace, any shell metacharacter
132/// (`;`, `|`, `&`, `$`, backtick, `(`, `)`, `<`, `>`, `*`, `?`, `~`,
133/// `!`, `#`, `'`, `"`, `\`), any control char, any non-ASCII (incl.
134/// emoji), the empty string.
135pub fn is_valid_id(s: &str) -> bool {
136    !s.is_empty()
137        && s.chars()
138            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
139}
140
141pub fn validate(compose: &Compose) -> Vec<ValidationError> {
142    let mut errs = Vec::new();
143
144    // Known runtimes. Embedded defaults are always present, so the
145    // validator can always enforce that every referenced runtime resolves
146    // to a descriptor (built in or user-supplied override).
147    let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
148    let check_runtime = !runtimes.is_empty();
149
150    match compose.global.broker.r#type.as_str() {
151        "sqlite" => {}
152        other => errs.push(ValidationError::UnknownBroker(other.into())),
153    }
154    match compose.global.supervisor.r#type.as_str() {
155        "tmux" | "systemd" | "launchd" => {}
156        other => errs.push(ValidationError::UnknownSupervisor(other.into())),
157    }
158    if compose.global.supervisor.drain_timeout_secs > 600 {
159        errs.push(ValidationError::DrainTimeoutOutOfRange(
160            compose.global.supervisor.drain_timeout_secs,
161        ));
162    }
163
164    // T-265 PR-a: compose schema version must be a valid semver
165    // string. Deserialization already accepted only (a) a string
166    // (passed verbatim into `SchemaVersion::value`) or (b) the
167    // legacy integer `2` (coerced to `"2.0.0"`); this validate-time
168    // check rejects in-string garbage like `"abc"` or `"2"` (the
169    // bare `"2"` is NOT semver — it needs the `.0.0` suffix).
170    // Delegated to the `semver` crate to get prerelease /
171    // build-metadata edge cases right rather than hand-rolling.
172    if semver::Version::parse(&compose.global.version.value).is_err() {
173        errs.push(ValidationError::SchemaVersionInvalid {
174            got: compose.global.version.value.clone(),
175        });
176    }
177
178    let mut seen_projects = BTreeSet::new();
179    for p in &compose.projects {
180        if !seen_projects.insert(p.project.id.clone()) {
181            errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
182        }
183        // T-310: id charset gate. Both `project.id` and the agent-id
184        // map keys flow unquoted into shell-bound strings + tmux
185        // session names downstream; rejecting unsafe chars at the
186        // boundary hardens every consumer at once.
187        if !is_valid_id(&p.project.id) {
188            errs.push(ValidationError::InvalidProjectId(p.project.id.clone()));
189        }
190        for id in p.managers.keys().chain(p.workers.keys()) {
191            if !is_valid_id(id) {
192                errs.push(ValidationError::InvalidAgentId {
193                    project: p.project.id.clone(),
194                    agent: id.clone(),
195                });
196            }
197        }
198
199        let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
200        let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
201        for dup in mgr_ids.intersection(&wrk_ids) {
202            errs.push(ValidationError::DuplicateAgent(
203                p.project.id.clone(),
204                (*dup).to_string(),
205            ));
206        }
207        let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
208
209        // Channel members reference known agents.
210        let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
211        for ch in &p.channels {
212            if let ChannelMembers::Explicit(members) = &ch.members {
213                for m in members {
214                    if !all_agents.contains(m.as_str()) {
215                        errs.push(ValidationError::ChannelUnknownMember {
216                            project: p.project.id.clone(),
217                            channel: ch.name.clone(),
218                            agent: m.clone(),
219                        });
220                    }
221                }
222            }
223        }
224
225        // Per-agent checks.
226        let check_agent = |errs: &mut Vec<ValidationError>,
227                           id: &str,
228                           a: &crate::compose::Agent,
229                           is_manager: bool| {
230            if a.telegram().is_some() && !is_manager {
231                errs.push(ValidationError::TelegramInboxOnWorker {
232                    project: p.project.id.clone(),
233                    agent: id.into(),
234                });
235            }
236            for t in &a.can_dm {
237                if !all_agents.contains(t.as_str()) {
238                    errs.push(ValidationError::DmUnknownTarget {
239                        project: p.project.id.clone(),
240                        agent: id.into(),
241                        target: t.clone(),
242                    });
243                }
244            }
245            for c in &a.can_broadcast {
246                if !channel_names.contains(c.as_str()) {
247                    errs.push(ValidationError::BroadcastUnknownChannel {
248                        project: p.project.id.clone(),
249                        agent: id.into(),
250                        channel: c.clone(),
251                    });
252                }
253            }
254            if let Some(t) = &a.reports_to {
255                if !mgr_ids.contains(t.as_str()) {
256                    errs.push(ValidationError::UnknownManager {
257                        project: p.project.id.clone(),
258                        agent: id.into(),
259                        target: t.clone(),
260                    });
261                }
262            }
263            if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
264                errs.push(ValidationError::UnknownRuntime {
265                    project: p.project.id.clone(),
266                    agent: id.into(),
267                    runtime: a.runtime.clone(),
268                });
269            }
270            if let Some(rp) = &a.role_prompt {
271                if rp.is_blank() {
272                    errs.push(ValidationError::BlankRolePrompt {
273                        project: p.project.id.clone(),
274                        agent: id.into(),
275                    });
276                }
277            }
278            if let Some(dn) = &a.display_name {
279                // T-160: trim before checking so `display_name: "   "`
280                // is rejected as blank rather than slipping through
281                // (whitespace-only labels render as a void cell in the
282                // TUI — same operator-confusion shape as empty).
283                let trimmed_len = dn.trim().chars().count();
284                if trimmed_len == 0 {
285                    errs.push(ValidationError::BlankDisplayName {
286                        project: p.project.id.clone(),
287                        agent: id.into(),
288                    });
289                } else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
290                    errs.push(ValidationError::DisplayNameTooLong {
291                        project: p.project.id.clone(),
292                        agent: id.into(),
293                        got: dn.chars().count(),
294                        max: DISPLAY_NAME_MAX_CHARS,
295                    });
296                }
297            }
298            // #383 Phase 4: `team` is the built-in mailbox MCP server,
299            // injected on every agent. A declared server of the same name
300            // would shadow the bus, so reject it at validate (render also
301            // skips it defensively).
302            if a.mcps.contains_key("team") {
303                errs.push(ValidationError::ReservedMcpServerName {
304                    project: p.project.id.clone(),
305                    agent: id.into(),
306                });
307            }
308        };
309
310        for (id, a) in &p.managers {
311            check_agent(&mut errs, id, a, true);
312        }
313        for (id, a) in &p.workers {
314            check_agent(&mut errs, id, a, false);
315        }
316    }
317
318    errs
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::compose::*;
325    use std::collections::BTreeMap;
326    use std::path::PathBuf;
327
328    fn toy_compose(agent_dm_target: &str) -> Compose {
329        let mut managers = BTreeMap::new();
330        managers.insert(
331            "mgr".into(),
332            Agent {
333                runtime: "claude-code".into(),
334                model: Some("claude-opus-4-8".into()),
335                role_prompt: None,
336                permission_mode: None,
337                autonomy: "low_risk_only".into(),
338                can_dm: vec![agent_dm_target.into()],
339                can_broadcast: vec!["team".into()],
340                reports_to: None,
341                on_rate_limit: None,
342                effort: None,
343                interfaces: None,
344                display_name: None,
345                hooks: vec![],
346                mcps: Default::default(),
347                subagents: vec![],
348                skills: vec![],
349            },
350        );
351        let mut workers = BTreeMap::new();
352        workers.insert(
353            "dev".into(),
354            Agent {
355                runtime: "claude-code".into(),
356                model: None,
357                role_prompt: None,
358                permission_mode: None,
359                autonomy: "low_risk_only".into(),
360                can_dm: vec!["mgr".into()],
361                can_broadcast: vec!["team".into()],
362                reports_to: Some("mgr".into()),
363                on_rate_limit: None,
364                effort: None,
365                interfaces: None,
366                display_name: None,
367                hooks: vec![],
368                mcps: Default::default(),
369                subagents: vec![],
370                skills: vec![],
371            },
372        );
373        Compose {
374            root: PathBuf::from("."),
375            global: Global {
376                version: crate::compose::SchemaVersion::new("2.0.0"),
377                broker: Default::default(),
378                supervisor: Default::default(),
379                budget: Default::default(),
380                hitl: Default::default(),
381                rate_limits: Default::default(),
382                interfaces: vec![],
383                projects: vec![],
384                attachments: Default::default(),
385            },
386            projects: vec![Project {
387                version: 2,
388                project: ProjectMeta {
389                    id: "hello".into(),
390                    name: "Hello".into(),
391                    cwd: PathBuf::from("."),
392                },
393                channels: vec![Channel {
394                    name: "team".into(),
395                    members: ChannelMembers::All("*".into()),
396                }],
397                managers,
398                workers,
399                interfaces: None,
400            }],
401        }
402    }
403
404    #[test]
405    fn clean_compose_validates() {
406        let c = toy_compose("dev");
407        assert_eq!(validate(&c), vec![]);
408    }
409
410    #[test]
411    fn dm_to_unknown_agent_flags() {
412        let c = toy_compose("ghost");
413        let e = validate(&c);
414        assert!(matches!(
415            e.as_slice(),
416            [ValidationError::DmUnknownTarget { .. }]
417        ));
418    }
419
420    #[test]
421    fn unknown_broker_flags() {
422        let mut c = toy_compose("dev");
423        c.global.broker.r#type = "redis".into();
424        assert!(validate(&c)
425            .iter()
426            .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
427    }
428
429    #[test]
430    fn drain_timeout_above_600s_flags() {
431        let mut c = toy_compose("dev");
432        c.global.supervisor.drain_timeout_secs = 86_400;
433        assert!(validate(&c)
434            .iter()
435            .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
436    }
437
438    #[test]
439    fn drain_timeout_zero_is_valid() {
440        let mut c = toy_compose("dev");
441        c.global.supervisor.drain_timeout_secs = 0;
442        assert!(!validate(&c)
443            .iter()
444            .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
445    }
446
447    #[test]
448    fn empty_role_prompt_list_flags() {
449        let mut c = toy_compose("dev");
450        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
451            Some(crate::compose::RolePrompt::Multiple(vec![]));
452        assert!(validate(&c)
453            .iter()
454            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
455    }
456
457    #[test]
458    fn empty_role_prompt_string_flags() {
459        // Pre-existing hole in the old `Option<PathBuf>` schema: an
460        // empty string slipped through and rendered
461        // `SYSTEM_PROMPT_PATH=<root>/`. Closed alongside the list
462        // form's empty-list check.
463        let mut c = toy_compose("dev");
464        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
465            Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
466        assert!(validate(&c)
467            .iter()
468            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
469    }
470
471    #[test]
472    fn single_role_prompt_validates() {
473        let mut c = toy_compose("dev");
474        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
475            crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
476        );
477        assert!(!validate(&c)
478            .iter()
479            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
480    }
481
482    #[test]
483    fn populated_role_prompt_list_validates() {
484        let mut c = toy_compose("dev");
485        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
486            crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
487        );
488        assert!(!validate(&c)
489            .iter()
490            .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
491    }
492
493    #[test]
494    fn blank_display_name_flags() {
495        let mut c = toy_compose("dev");
496        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
497        assert!(validate(&c)
498            .iter()
499            .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
500    }
501
502    #[test]
503    fn declared_mcp_server_named_team_flags() {
504        // #383 Phase 4: `team` is the reserved built-in mailbox server;
505        // declaring one of the same name must be a validation error.
506        let mut c = toy_compose("dev");
507        let mut mcps = std::collections::BTreeMap::new();
508        mcps.insert(
509            "team".into(),
510            crate::compose::McpServer {
511                command: "evil".into(),
512                args: vec![],
513                env: Default::default(),
514            },
515        );
516        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
517        assert!(validate(&c)
518            .iter()
519            .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
520    }
521
522    #[test]
523    fn declared_mcp_server_with_normal_name_validates() {
524        // A non-reserved server name must NOT trip the reserved check.
525        let mut c = toy_compose("dev");
526        let mut mcps = std::collections::BTreeMap::new();
527        mcps.insert(
528            "github".into(),
529            crate::compose::McpServer {
530                command: "npx".into(),
531                args: vec![],
532                env: Default::default(),
533            },
534        );
535        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
536        assert!(!validate(&c)
537            .iter()
538            .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
539    }
540
541    #[test]
542    fn display_name_at_max_length_validates() {
543        let mut c = toy_compose("dev");
544        let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
545        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
546        assert!(!validate(&c).iter().any(|e| matches!(
547            e,
548            ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
549        )));
550    }
551
552    #[test]
553    fn display_name_above_max_length_flags() {
554        let mut c = toy_compose("dev");
555        let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
556        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
557        assert!(validate(&c)
558            .iter()
559            .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
560    }
561
562    #[test]
563    fn display_name_counts_chars_not_bytes() {
564        // T-160: limit applies to Unicode-scalar-value (`chars()`)
565        // count, not bytes. Each `🦀` is one char but four UTF-8
566        // bytes; 64 crabs must still validate even though that's 256
567        // bytes on disk.
568        let mut c = toy_compose("dev");
569        let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
570        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
571        assert!(!validate(&c).iter().any(|e| matches!(
572            e,
573            ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
574        )));
575    }
576
577    #[test]
578    fn whitespace_only_display_name_flags_blank() {
579        // T-160 qa follow-up: whitespace-only labels render as a void
580        // cell in the TUI — reject under the same `BlankDisplayName`
581        // error as empty strings so the operator gets a clear message.
582        let mut c = toy_compose("dev");
583        c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some("   ".into());
584        assert!(validate(&c)
585            .iter()
586            .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
587    }
588
589    #[test]
590    fn populated_display_name_validates() {
591        let mut c = toy_compose("dev");
592        c.projects[0].managers.get_mut("mgr").unwrap().display_name =
593            Some("Sage (Visionary)".into());
594        assert!(!validate(&c).iter().any(|e| matches!(
595            e,
596            ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
597        )));
598    }
599
600    // ── T-310: id charset validation ────────────────────────────────────
601
602    #[test]
603    fn is_valid_id_accepts_existing_id_shapes() {
604        // Pin the conformant shapes currently in use across dogfood,
605        // cookbook, tests, and conventional fixtures — none of these
606        // may regress.
607        for ok in [
608            "teamctl",
609            "ops",
610            "nico",
611            "eng_lead",
612            "pr-22-review",
613            "blog-site",
614            "my.team",
615            "a1",
616            "x-2.0",
617            "a",
618            "0",
619            "A",
620            "_",
621            "-",
622            ".",
623        ] {
624            assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
625        }
626    }
627
628    #[test]
629    fn is_valid_id_rejects_shell_metacharacter_class() {
630        // The qa PoC class on #310: any shell metacharacter in an id
631        // would flow unquoted into `build_up_command`. Each of these
632        // must be rejected at the boundary.
633        for bad in [
634            "evil; rm",
635            "proj$(id)",
636            "with space",
637            "back`ticks`",
638            "p|ipe",
639            "p&amp",
640            "p*g",
641            "p?g",
642            "p~e",
643            "p!g",
644            "p#g",
645            "p'q",
646            "p\"q",
647            "p\\g",
648            "p<g",
649            "p>g",
650            "p(g",
651            "p)g",
652            "p\tg",
653            "p\ng",
654        ] {
655            assert!(!is_valid_id(bad), "must reject `{bad:?}`");
656        }
657    }
658
659    #[test]
660    fn is_valid_id_rejects_colon_as_reserved_separator() {
661        // `:` is reserved for the canonical `<project>:<agent>` join;
662        // admitting it into an id would make `AgentHandle::id()`
663        // ambiguous *and* would still flow unquoted into shell-bound
664        // strings.
665        assert!(!is_valid_id("p:rj"));
666        assert!(!is_valid_id(":"));
667        assert!(!is_valid_id("a:"));
668        assert!(!is_valid_id(":a"));
669    }
670
671    #[test]
672    fn is_valid_id_rejects_empty_and_control_chars() {
673        assert!(!is_valid_id(""));
674        assert!(!is_valid_id("\0"));
675        assert!(!is_valid_id("p\x07q"));
676    }
677
678    #[test]
679    fn is_valid_id_rejects_non_ascii() {
680        // Emoji and Unicode letters are operationally bad even though
681        // some are shell-inert; stay strictly ASCII per the issue.
682        assert!(!is_valid_id("crab🦀"));
683        assert!(!is_valid_id("café"));
684    }
685
686    #[test]
687    fn clean_compose_passes_id_charset() {
688        // Regression for conformant teams (acceptance criterion 5):
689        // existing valid ids are unaffected.
690        let c = toy_compose("dev");
691        let errs = validate(&c);
692        assert!(
693            !errs.iter().any(|e| matches!(
694                e,
695                ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
696            )),
697            "clean compose unexpectedly flagged for id charset: {errs:?}",
698        );
699    }
700
701    #[test]
702    fn project_id_with_shell_metacharacters_flags() {
703        // qa PoC class regression (acceptance criterion 4): a
704        // `project.id` containing shell-metacharacter content is
705        // rejected by `validate` *before* it could reach
706        // `build_up_command`'s unquoted shell interpolation on
707        // `teamctl up` / `reload` / `down`.
708        let mut c = toy_compose("dev");
709        c.projects[0].project.id = "evil; rm -rf ~".into();
710        let errs = validate(&c);
711        assert!(
712            errs.iter().any(|e| matches!(
713                e,
714                ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
715            )),
716            "expected InvalidProjectId, got {errs:?}",
717        );
718    }
719
720    #[test]
721    fn manager_id_with_shell_metacharacters_flags() {
722        // Same PoC class via the manager-key path — `compose.agents()`
723        // yields these ids and the supervisor flows them unquoted into
724        // `{project}:{agent}`.
725        let mut c = toy_compose("dev");
726        let bad = "$(id)";
727        let mgr = c.projects[0].managers.remove("mgr").unwrap();
728        c.projects[0].managers.insert(bad.into(), mgr);
729        let errs = validate(&c);
730        assert!(
731            errs.iter().any(|e| matches!(
732                e,
733                ValidationError::InvalidAgentId { project, agent }
734                    if project == "hello" && agent == bad
735            )),
736            "expected InvalidAgentId for manager, got {errs:?}",
737        );
738    }
739
740    #[test]
741    fn worker_id_with_shell_metacharacters_flags() {
742        // Same PoC class via the worker-key path.
743        let mut c = toy_compose("dev");
744        let bad = "rogue|pipe";
745        let wkr = c.projects[0].workers.remove("dev").unwrap();
746        c.projects[0].workers.insert(bad.into(), wkr);
747        // The dm target `dev` is also stale now — filter for the
748        // charset error specifically.
749        let errs = validate(&c);
750        assert!(
751            errs.iter().any(|e| matches!(
752                e,
753                ValidationError::InvalidAgentId { project, agent }
754                    if project == "hello" && agent == bad
755            )),
756            "expected InvalidAgentId for worker, got {errs:?}",
757        );
758    }
759
760    #[test]
761    fn project_id_with_reserved_colon_flags() {
762        // `:` is the canonical project:agent join — admitting it would
763        // make routing parse ambiguous *and* still leak unquoted into
764        // shell strings.
765        let mut c = toy_compose("dev");
766        c.projects[0].project.id = "foo:bar".into();
767        let errs = validate(&c);
768        assert!(
769            errs.iter()
770                .any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
771            "expected InvalidProjectId on colon, got {errs:?}",
772        );
773    }
774
775    // T-265 PR-a: schema version semver-shape check. Deserialize
776    // accepts any string verbatim (narrow concern); this validate
777    // step is what enforces the shape.
778
779    #[test]
780    fn valid_semver_string_validates() {
781        // Default fixture uses "2.0.0" — the canonical form.
782        let c = toy_compose("dev");
783        assert!(
784            !validate(&c)
785                .iter()
786                .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
787            "canonical version `2.0.0` must validate"
788        );
789    }
790
791    #[test]
792    fn malformed_semver_string_flags() {
793        let mut c = toy_compose("dev");
794        c.global.version = crate::compose::SchemaVersion::new("abc");
795        let errs = validate(&c);
796        assert!(
797            errs.iter().any(|e| matches!(
798                e,
799                ValidationError::SchemaVersionInvalid { got } if got == "abc"
800            )),
801            "non-semver string must surface SchemaVersionInvalid; got {errs:?}"
802        );
803    }
804
805    #[test]
806    fn bare_two_string_flags_too() {
807        // `"2"` is NOT semver (needs `.0.0`). Bare-2-string would
808        // sneak past if our check were too loose; pin it explicitly.
809        let mut c = toy_compose("dev");
810        c.global.version = crate::compose::SchemaVersion::new("2");
811        assert!(
812            validate(&c).iter().any(|e| matches!(
813                e,
814                ValidationError::SchemaVersionInvalid { got } if got == "2"
815            )),
816            "bare-2-string must NOT pass the semver shape check"
817        );
818    }
819
820    #[test]
821    fn semver_with_prerelease_and_build_metadata_validates() {
822        // Real-world semver supports `-pre` + `+build` suffixes.
823        // Delegating to the `semver` crate gets these right;
824        // pin the contract so a future "let's hand-roll a regex"
825        // refactor surfaces here.
826        for ok in [
827            "1.0.0",
828            "2.3.4",
829            "2.0.0-alpha",
830            "1.0.0+build.5",
831            "2.0.0-rc.1+build.7",
832        ] {
833            let mut c = toy_compose("dev");
834            c.global.version = crate::compose::SchemaVersion::new(ok);
835            assert!(
836                !validate(&c)
837                    .iter()
838                    .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
839                "semver `{ok}` must validate"
840            );
841        }
842    }
843}