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