1use crate::MobBackendKind;
8use crate::definition::MobDefinition;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum DiagnosticCode {
16 MissingSkillRef,
18 MissingMcpRef,
20 MissingOrchestratorProfile,
22 InvalidProfileName,
24 InvalidWiringProfile,
26 EmptyProfiles,
28 MissingExternalBackendConfig,
30 InvalidExternalBackendConfig,
32 FlowCycleDetected,
34 FlowUnknownStep,
36 FlowUnknownRole,
38 FlowDepthExceeded,
40 TopologyUnknownRole,
42 QuorumInvalid,
44 BranchGroupEmpty,
46 BranchStepMissingCondition,
48 BranchStepConflictingDeps,
50 BranchJoinWithoutBranch,
52 ReservedSystemIdentifier,
54 InvalidInlinePeerNotificationThreshold,
56}
57
58impl fmt::Display for DiagnosticCode {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 let s = match self {
61 Self::MissingSkillRef => "missing_skill_ref",
62 Self::MissingMcpRef => "missing_mcp_ref",
63 Self::MissingOrchestratorProfile => "missing_orchestrator_profile",
64 Self::InvalidProfileName => "invalid_profile_name",
65 Self::InvalidWiringProfile => "invalid_wiring_profile",
66 Self::EmptyProfiles => "empty_profiles",
67 Self::MissingExternalBackendConfig => "missing_external_backend_config",
68 Self::InvalidExternalBackendConfig => "invalid_external_backend_config",
69 Self::FlowCycleDetected => "flow_cycle_detected",
70 Self::FlowUnknownStep => "flow_unknown_step",
71 Self::FlowUnknownRole => "flow_unknown_role",
72 Self::FlowDepthExceeded => "flow_depth_exceeded",
73 Self::TopologyUnknownRole => "topology_unknown_role",
74 Self::QuorumInvalid => "quorum_invalid",
75 Self::BranchGroupEmpty => "branch_group_empty",
76 Self::BranchStepMissingCondition => "branch_step_missing_condition",
77 Self::BranchStepConflictingDeps => "branch_step_conflicting_deps",
78 Self::BranchJoinWithoutBranch => "branch_join_without_branch",
79 Self::ReservedSystemIdentifier => "reserved_system_identifier",
80 Self::InvalidInlinePeerNotificationThreshold => {
81 "invalid_inline_peer_notification_threshold"
82 }
83 };
84 f.write_str(s)
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum DiagnosticSeverity {
92 Error,
93 Warning,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Diagnostic {
99 pub code: DiagnosticCode,
101 pub message: String,
103 pub location: Option<String>,
105 pub severity: DiagnosticSeverity,
107}
108
109pub fn validate_definition(def: &MobDefinition) -> Vec<Diagnostic> {
113 let mut diagnostics = Vec::new();
114
115 if def.profiles.is_empty() {
116 diagnostics.push(Diagnostic {
117 code: DiagnosticCode::EmptyProfiles,
118 message: "mob definition must define at least one profile".to_string(),
119 location: Some("profiles".to_string()),
120 severity: DiagnosticSeverity::Error,
121 });
122 }
123
124 if let Some(orch) = &def.orchestrator
126 && !def.profiles.contains_key(&orch.profile)
127 {
128 diagnostics.push(Diagnostic {
129 code: DiagnosticCode::MissingOrchestratorProfile,
130 message: format!(
131 "orchestrator profile '{}' is not defined",
132 orch.profile.as_str()
133 ),
134 location: Some("mob.orchestrator".to_string()),
135 severity: DiagnosticSeverity::Error,
136 });
137 }
138
139 for (name, binding) in &def.profiles {
141 if !is_valid_identifier(name.as_str()) {
143 diagnostics.push(Diagnostic {
144 code: DiagnosticCode::InvalidProfileName,
145 message: format!("profile name '{}' is not a valid identifier", name.as_str()),
146 location: Some(format!("profiles.{}", name.as_str())),
147 severity: DiagnosticSeverity::Error,
148 });
149 }
150 if name.as_str() == crate::runtime::flow_system_member_id().as_str()
151 || name
152 .as_str()
153 .starts_with(crate::runtime::FLOW_SYSTEM_MEMBER_ID_PREFIX)
154 {
155 diagnostics.push(Diagnostic {
156 code: DiagnosticCode::ReservedSystemIdentifier,
157 message: format!(
158 "profile name '{}' uses reserved flow-system identifier namespace",
159 name.as_str()
160 ),
161 location: Some(format!("profiles.{}", name.as_str())),
162 severity: DiagnosticSeverity::Error,
163 });
164 }
165
166 let Some(profile) = binding.as_inline() else {
168 continue;
169 };
170
171 for (i, skill_ref) in profile.skills.iter().enumerate() {
173 if !def.skills.contains_key(skill_ref) {
174 diagnostics.push(Diagnostic {
175 code: DiagnosticCode::MissingSkillRef,
176 message: format!("skill '{skill_ref}' is not defined"),
177 location: Some(format!("profiles.{}.skills[{}]", name.as_str(), i)),
178 severity: DiagnosticSeverity::Error,
179 });
180 }
181 }
182
183 for (i, mcp_ref) in profile.tools.mcp.iter().enumerate() {
185 if !def.mcp_servers.contains_key(mcp_ref) {
186 diagnostics.push(Diagnostic {
187 code: DiagnosticCode::MissingMcpRef,
188 message: format!("MCP server '{mcp_ref}' is not defined"),
189 location: Some(format!("profiles.{}.tools.mcp[{}]", name.as_str(), i)),
190 severity: DiagnosticSeverity::Error,
191 });
192 }
193 }
194
195 if let Some(threshold) = profile.max_inline_peer_notifications
196 && threshold < -1
197 {
198 diagnostics.push(Diagnostic {
199 code: DiagnosticCode::InvalidInlinePeerNotificationThreshold,
200 message: format!(
201 "profiles.{} max_inline_peer_notifications={} is invalid (allowed: -1, 0, or >0)",
202 name.as_str(),
203 threshold
204 ),
205 location: Some(format!(
206 "profiles.{}.max_inline_peer_notifications",
207 name.as_str()
208 )),
209 severity: DiagnosticSeverity::Error,
210 });
211 }
212 }
213
214 for (i, rule) in def.wiring.role_wiring.iter().enumerate() {
216 if !def.profiles.contains_key(&rule.a) {
217 diagnostics.push(Diagnostic {
218 code: DiagnosticCode::InvalidWiringProfile,
219 message: format!(
220 "wiring rule references non-existent profile '{}'",
221 rule.a.as_str()
222 ),
223 location: Some(format!("wiring.role_wiring[{i}].a")),
224 severity: DiagnosticSeverity::Error,
225 });
226 }
227 if !def.profiles.contains_key(&rule.b) {
228 diagnostics.push(Diagnostic {
229 code: DiagnosticCode::InvalidWiringProfile,
230 message: format!(
231 "wiring rule references non-existent profile '{}'",
232 rule.b.as_str()
233 ),
234 location: Some(format!("wiring.role_wiring[{i}].b")),
235 severity: DiagnosticSeverity::Error,
236 });
237 }
238 }
239
240 let definition_uses_external_default = def.backend.default == MobBackendKind::External;
241 let profile_uses_external = def
242 .profiles
243 .values()
244 .filter_map(|b| b.as_inline())
245 .any(|profile| profile.backend == Some(MobBackendKind::External));
246 if definition_uses_external_default || profile_uses_external {
247 match &def.backend.external {
248 None => diagnostics.push(Diagnostic {
249 code: DiagnosticCode::MissingExternalBackendConfig,
250 message: "external backend selected but backend.external config is missing"
251 .to_string(),
252 location: Some("backend.external".to_string()),
253 severity: DiagnosticSeverity::Error,
254 }),
255 Some(external) if external.address_base.trim().is_empty() => {
256 diagnostics.push(Diagnostic {
257 code: DiagnosticCode::InvalidExternalBackendConfig,
258 message: "backend.external.address_base must not be empty".to_string(),
259 location: Some("backend.external.address_base".to_string()),
260 severity: DiagnosticSeverity::Error,
261 });
262 }
263 Some(_) => {}
264 }
265 }
266
267 diagnostics
268}
269
270pub fn partition_diagnostics(
272 diagnostics: impl IntoIterator<Item = Diagnostic>,
273) -> (Vec<Diagnostic>, Vec<Diagnostic>) {
274 diagnostics
275 .into_iter()
276 .partition(|diag| diag.severity == DiagnosticSeverity::Error)
277}
278
279fn is_valid_identifier(s: &str) -> bool {
281 if s.is_empty() {
282 return false;
283 }
284 let first = s.chars().next().unwrap_or(' ');
286 if !first.is_ascii_alphabetic() && first != '_' {
287 return false;
288 }
289 s.chars()
291 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::definition::{
298 BackendConfig, McpServerConfig, MobDefinition, OrchestratorConfig, RoleWiringRule,
299 SkillSource, WiringRules,
300 };
301 use crate::ids::{MobId, ProfileName};
302 use crate::profile::{Profile, ProfileBinding, ToolConfig};
303 use std::collections::BTreeMap;
304
305 fn base_profile() -> Profile {
306 Profile {
307 model: "claude-opus-4-6".to_string(),
308 skills: vec![],
309 tools: ToolConfig::default(),
310 peer_description: "test".to_string(),
311 external_addressable: false,
312 backend: None,
313 runtime_mode: crate::MobRuntimeMode::AutonomousHost,
314 max_inline_peer_notifications: None,
315 output_schema: None,
316 provider_params: None,
317 }
318 }
319
320 fn valid_definition() -> MobDefinition {
321 let mut profiles = BTreeMap::new();
322 profiles.insert(
323 ProfileName::from("lead"),
324 ProfileBinding::Inline({
325 let mut p = base_profile();
326 p.skills = vec!["skill-a".to_string()];
327 p.tools.mcp = vec!["server-a".to_string()];
328 p
329 }),
330 );
331 profiles.insert(
332 ProfileName::from("worker"),
333 ProfileBinding::Inline(base_profile()),
334 );
335
336 let mut skills = BTreeMap::new();
337 skills.insert(
338 "skill-a".to_string(),
339 SkillSource::Inline {
340 content: "You are a leader.".to_string(),
341 },
342 );
343
344 let mut mcp_servers = BTreeMap::new();
345 mcp_servers.insert(
346 "server-a".to_string(),
347 McpServerConfig {
348 command: vec!["node".to_string(), "server.js".to_string()],
349 url: None,
350 env: BTreeMap::new(),
351 },
352 );
353
354 MobDefinition {
355 id: MobId::from("test-mob"),
356 orchestrator: Some(OrchestratorConfig {
357 profile: ProfileName::from("lead"),
358 }),
359 profiles,
360 mcp_servers,
361 wiring: WiringRules {
362 auto_wire_orchestrator: true,
363 role_wiring: vec![RoleWiringRule {
364 a: ProfileName::from("lead"),
365 b: ProfileName::from("worker"),
366 }],
367 },
368 skills,
369 backend: BackendConfig::default(),
370 flows: BTreeMap::new(),
371 topology: None,
372 supervisor: None,
373 limits: None,
374 spawn_policy: None,
375 event_router: None,
376 owner_session_id: None,
377 session_cleanup_policy: crate::definition::SessionCleanupPolicy::Manual,
378 is_implicit: false,
379 }
380 }
381
382 #[test]
383 fn test_valid_definition_passes() {
384 let diagnostics = validate_definition(&valid_definition());
385 assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
386 }
387
388 #[test]
389 fn test_empty_profiles_is_invalid() {
390 let mut def = valid_definition();
391 def.profiles.clear();
392 let diagnostics = validate_definition(&def);
393 assert!(
394 diagnostics
395 .iter()
396 .any(|d| d.code == DiagnosticCode::EmptyProfiles),
397 "empty profile map must be rejected"
398 );
399 }
400
401 #[test]
402 fn test_missing_skill_ref() {
403 let mut def = valid_definition();
404 def.profiles
405 .get_mut(&ProfileName::from("lead"))
406 .unwrap()
407 .as_inline_mut()
408 .unwrap()
409 .skills
410 .push("nonexistent-skill".to_string());
411
412 let diagnostics = validate_definition(&def);
413 assert_eq!(diagnostics.len(), 1);
414 assert_eq!(diagnostics[0].code, DiagnosticCode::MissingSkillRef);
415 assert!(diagnostics[0].message.contains("nonexistent-skill"));
416 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
417 }
418
419 #[test]
420 fn test_missing_mcp_ref() {
421 let mut def = valid_definition();
422 def.profiles
423 .get_mut(&ProfileName::from("lead"))
424 .unwrap()
425 .as_inline_mut()
426 .unwrap()
427 .tools
428 .mcp
429 .push("nonexistent-mcp".to_string());
430
431 let diagnostics = validate_definition(&def);
432 assert_eq!(diagnostics.len(), 1);
433 assert_eq!(diagnostics[0].code, DiagnosticCode::MissingMcpRef);
434 assert!(diagnostics[0].message.contains("nonexistent-mcp"));
435 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
436 }
437
438 #[test]
439 fn test_missing_orchestrator_profile() {
440 let mut def = valid_definition();
441 def.orchestrator = Some(OrchestratorConfig {
442 profile: ProfileName::from("nonexistent"),
443 });
444
445 let diagnostics = validate_definition(&def);
446 assert!(
447 diagnostics
448 .iter()
449 .any(|d| d.code == DiagnosticCode::MissingOrchestratorProfile)
450 );
451 }
452
453 #[test]
454 fn test_invalid_profile_name() {
455 let mut def = valid_definition();
456 def.profiles.insert(
457 ProfileName::from("123-invalid"),
458 ProfileBinding::Inline(base_profile()),
459 );
460
461 let diagnostics = validate_definition(&def);
462 assert!(
463 diagnostics
464 .iter()
465 .any(|d| d.code == DiagnosticCode::InvalidProfileName)
466 );
467 }
468
469 #[test]
470 fn test_reserved_system_profile_name_rejected() {
471 let mut def = valid_definition();
472 def.profiles.insert(
473 crate::runtime::flow_system_member_id().as_str().into(),
474 ProfileBinding::Inline(base_profile()),
475 );
476
477 let diagnostics = validate_definition(&def);
478 assert!(diagnostics.iter().any(|d| {
479 d.code == DiagnosticCode::ReservedSystemIdentifier
480 && d.message.contains("reserved flow-system identifier")
481 }));
482 }
483
484 #[test]
485 fn test_invalid_wiring_profile() {
486 let mut def = valid_definition();
487 def.wiring.role_wiring.push(RoleWiringRule {
488 a: ProfileName::from("nonexistent-a"),
489 b: ProfileName::from("nonexistent-b"),
490 });
491
492 let diagnostics = validate_definition(&def);
493 let wiring_diags: Vec<_> = diagnostics
494 .iter()
495 .filter(|d| d.code == DiagnosticCode::InvalidWiringProfile)
496 .collect();
497 assert_eq!(wiring_diags.len(), 2);
498 }
499
500 #[test]
501 fn test_multiple_errors() {
502 let mut def = valid_definition();
503 def.orchestrator = Some(OrchestratorConfig {
504 profile: ProfileName::from("gone"),
505 });
506 def.profiles
507 .get_mut(&ProfileName::from("lead"))
508 .unwrap()
509 .as_inline_mut()
510 .unwrap()
511 .skills
512 .push("bad-skill".to_string());
513 def.profiles
514 .get_mut(&ProfileName::from("lead"))
515 .unwrap()
516 .as_inline_mut()
517 .unwrap()
518 .tools
519 .mcp
520 .push("bad-mcp".to_string());
521
522 let diagnostics = validate_definition(&def);
523 assert!(diagnostics.len() >= 3);
524 }
525
526 #[test]
527 fn test_external_backend_requires_external_config() {
528 let mut def = valid_definition();
529 def.backend.default = MobBackendKind::External;
530 let diagnostics = validate_definition(&def);
531 assert!(
532 diagnostics
533 .iter()
534 .any(|d| d.code == DiagnosticCode::MissingExternalBackendConfig)
535 );
536 }
537
538 #[test]
539 fn test_external_backend_rejects_empty_address_base() {
540 let mut def = valid_definition();
541 def.profiles
542 .get_mut(&ProfileName::from("worker"))
543 .expect("worker profile exists")
544 .as_inline_mut()
545 .unwrap()
546 .backend = Some(MobBackendKind::External);
547 def.backend.external = Some(crate::definition::ExternalBackendConfig {
548 address_base: " ".to_string(),
549 });
550 let diagnostics = validate_definition(&def);
551 assert!(
552 diagnostics
553 .iter()
554 .any(|d| d.code == DiagnosticCode::InvalidExternalBackendConfig)
555 );
556 }
557
558 #[test]
559 fn test_parse_and_validate_rejects_missing_external_backend_config() {
560 let toml = r#"
561[mob]
562id = "mob-ext"
563
564[backend]
565default = "external"
566
567[profiles.worker]
568model = "claude-sonnet-4-5"
569"#;
570 let def = MobDefinition::from_toml(toml).expect("parse toml");
571 let diagnostics = validate_definition(&def);
572 assert!(
573 diagnostics
574 .iter()
575 .any(|d| d.code == DiagnosticCode::MissingExternalBackendConfig)
576 );
577 }
578
579 #[test]
580 fn test_invalid_inline_peer_notification_threshold_is_rejected() {
581 let mut def = valid_definition();
582 def.profiles
583 .get_mut(&ProfileName::from("lead"))
584 .expect("lead profile")
585 .as_inline_mut()
586 .unwrap()
587 .max_inline_peer_notifications = Some(-2);
588
589 let diagnostics = validate_definition(&def);
590 assert!(
591 diagnostics.iter().any(|d| {
592 d.code == DiagnosticCode::InvalidInlinePeerNotificationThreshold
593 && d.location.as_deref() == Some("profiles.lead.max_inline_peer_notifications")
594 }),
595 "expected invalid inline threshold diagnostic"
596 );
597 }
598
599 #[test]
600 fn test_valid_identifier_patterns() {
601 assert!(is_valid_identifier("worker"));
602 assert!(is_valid_identifier("lead_agent"));
603 assert!(is_valid_identifier("agent-1"));
604 assert!(is_valid_identifier("_private"));
605 assert!(!is_valid_identifier(""));
606 assert!(!is_valid_identifier("123bad"));
607 assert!(!is_valid_identifier("-start"));
608 assert!(!is_valid_identifier("has space"));
609 }
610
611 #[test]
612 fn test_diagnostic_serde_roundtrip() {
613 let diag = Diagnostic {
614 code: DiagnosticCode::MissingSkillRef,
615 message: "skill 'foo' not found".to_string(),
616 location: Some("profiles.worker.skills[0]".to_string()),
617 severity: DiagnosticSeverity::Error,
618 };
619 let json = serde_json::to_string(&diag).unwrap();
620 let parsed: Diagnostic = serde_json::from_str(&json).unwrap();
621 assert_eq!(parsed, diag);
622 }
623
624 #[test]
625 fn test_partition_diagnostics() {
626 let diagnostics = vec![
627 Diagnostic {
628 code: DiagnosticCode::MissingSkillRef,
629 message: "missing".to_string(),
630 location: None,
631 severity: DiagnosticSeverity::Error,
632 },
633 Diagnostic {
634 code: DiagnosticCode::BranchJoinWithoutBranch,
635 message: "warn".to_string(),
636 location: None,
637 severity: DiagnosticSeverity::Warning,
638 },
639 ];
640 let (errors, warnings) = partition_diagnostics(diagnostics);
641 assert_eq!(errors.len(), 1);
642 assert_eq!(warnings.len(), 1);
643 assert_eq!(errors[0].severity, DiagnosticSeverity::Error);
644 assert_eq!(warnings[0].severity, DiagnosticSeverity::Warning);
645 }
646
647 #[test]
648 fn test_minimal_valid_definition() {
649 let def = MobDefinition {
650 id: MobId::from("minimal"),
651 orchestrator: None,
652 profiles: BTreeMap::new(),
653 mcp_servers: BTreeMap::new(),
654 wiring: WiringRules::default(),
655 skills: BTreeMap::new(),
656 backend: BackendConfig::default(),
657 flows: BTreeMap::new(),
658 topology: None,
659 supervisor: None,
660 limits: None,
661 spawn_policy: None,
662 event_router: None,
663 owner_session_id: None,
664 session_cleanup_policy: crate::definition::SessionCleanupPolicy::Manual,
665 is_implicit: false,
666 };
667 let diagnostics = validate_definition(&def);
668 assert!(
669 diagnostics
670 .iter()
671 .any(|d| d.code == DiagnosticCode::EmptyProfiles),
672 "minimal definition without profiles should fail validation"
673 );
674 }
675}