1use serde_json::Value;
2use std::net::UdpSocket;
3use std::path::Path;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6#[derive(Debug)]
12pub struct ValidationError {
13 pub layer: &'static str,
14 pub location: Option<String>,
15 pub message: String,
16}
17
18#[derive(Debug)]
20pub struct ValidationReport {
21 pub json_ok: bool,
22 pub structure_ok: bool,
23 pub boxes_count: usize,
24 pub lines_count: usize,
25 pub errors: Vec<ValidationError>,
26 pub warnings: Vec<String>,
27}
28
29#[derive(Debug)]
31pub struct MaxValidationResult {
32 pub status: String,
33 pub errors: Vec<MaxValidationError>,
34 pub warnings: Vec<String>,
35 pub boxes_checked: usize,
36 pub lines_checked: usize,
37}
38
39#[derive(Debug)]
41pub struct MaxValidationError {
42 pub error_type: String,
43 pub box_id: Option<String>,
44 pub message: String,
45}
46
47#[derive(Debug)]
49pub struct ValidateOptions {
50 pub ci_only: bool,
51 pub max_only: bool,
52 pub full: bool,
53 pub port: u16,
54 pub timeout: Duration,
55 pub input_path: String,
56}
57
58impl Default for ValidateOptions {
59 fn default() -> Self {
60 Self {
61 ci_only: false,
62 max_only: false,
63 full: false,
64 port: 7401,
65 timeout: Duration::from_secs(10),
66 input_path: String::new(),
67 }
68 }
69}
70
71pub fn parse_validate_args(args: &[String]) -> Result<ValidateOptions, String> {
76 let mut opts = ValidateOptions::default();
77 let mut i = 0;
78
79 while i < args.len() {
80 match args[i].as_str() {
81 "--ci" => {
82 opts.ci_only = true;
83 i += 1;
84 }
85 "--max" => {
86 opts.max_only = true;
87 i += 1;
88 }
89 "--full" => {
90 opts.full = true;
91 i += 1;
92 }
93 "--port" => {
94 if i + 1 >= args.len() {
95 return Err("--port requires a port number argument".to_string());
96 }
97 opts.port = args[i + 1]
98 .parse::<u16>()
99 .map_err(|e| format!("invalid port number '{}': {}", args[i + 1], e))?;
100 i += 2;
101 }
102 "--timeout" => {
103 if i + 1 >= args.len() {
104 return Err("--timeout requires a seconds argument".to_string());
105 }
106 let secs = args[i + 1]
107 .parse::<u64>()
108 .map_err(|e| format!("invalid timeout '{}': {}", args[i + 1], e))?;
109 opts.timeout = Duration::from_secs(secs);
110 i += 2;
111 }
112 "--help" | "-h" => {
113 return Err(String::new()); }
115 arg if arg.starts_with('-') => {
116 return Err(format!("unknown option '{}'", arg));
117 }
118 arg => {
119 if !opts.input_path.is_empty() {
120 return Err(format!("unexpected argument '{}'", arg));
121 }
122 opts.input_path = arg.to_string();
123 i += 1;
124 }
125 }
126 }
127
128 if opts.input_path.is_empty() {
129 return Err("missing input file path".to_string());
130 }
131
132 let flag_count = [opts.ci_only, opts.max_only, opts.full]
134 .iter()
135 .filter(|&&b| b)
136 .count();
137 if flag_count > 1 {
138 return Err("--ci, --max, and --full are mutually exclusive".to_string());
139 }
140
141 Ok(opts)
142}
143
144fn validate_json_syntax(content: &str) -> Result<Value, ValidationError> {
150 serde_json::from_str::<Value>(content).map_err(|e| ValidationError {
151 layer: "Layer 1",
152 location: Some(format!("line {}, column {}", e.line(), e.column())),
153 message: format!("JSON parse error: {}", e),
154 })
155}
156
157fn validate_structure(json: &Value) -> (usize, usize, Vec<ValidationError>) {
159 let mut errors = Vec::new();
160 let mut boxes_count = 0;
161 let mut lines_count = 0;
162
163 let patcher = match json.get("patcher") {
164 Some(p) => p,
165 None => {
166 errors.push(ValidationError {
167 layer: "Layer 1",
168 location: None,
169 message: "'patcher' root object not found".to_string(),
170 });
171 return (0, 0, errors);
172 }
173 };
174
175 for field in &["fileversion", "boxes", "lines"] {
177 if patcher.get(field).is_none() {
178 errors.push(ValidationError {
179 layer: "Layer 1",
180 location: Some(format!("patcher.{}", field)),
181 message: format!("missing required field '{}'", field),
182 });
183 }
184 }
185
186 if let Some(boxes) = patcher.get("boxes").and_then(|b| b.as_array()) {
188 boxes_count = boxes.len();
189 for (i, item) in boxes.iter().enumerate() {
190 match item.get("box") {
191 None => {
192 errors.push(ValidationError {
193 layer: "Layer 1",
194 location: Some(format!("patcher.boxes[{}]", i)),
195 message: "missing 'box' wrapper".to_string(),
196 });
197 }
198 Some(b) => {
199 for field in &["id", "maxclass", "numinlets", "numoutlets", "patching_rect"] {
200 if b.get(field).is_none() {
201 errors.push(ValidationError {
202 layer: "Layer 1",
203 location: Some(format!("patcher.boxes[{}].box", i)),
204 message: format!("missing required field '{}'", field),
205 });
206 }
207 }
208 }
209 }
210 }
211 }
212
213 if let Some(lines) = patcher.get("lines").and_then(|l| l.as_array()) {
215 lines_count = lines.len();
216
217 let box_ids: std::collections::HashSet<String> = patcher
219 .get("boxes")
220 .and_then(|b| b.as_array())
221 .map(|boxes| {
222 boxes
223 .iter()
224 .filter_map(|item| {
225 item.get("box")
226 .and_then(|b| b.get("id"))
227 .and_then(|id| id.as_str())
228 .map(|s| s.to_string())
229 })
230 .collect()
231 })
232 .unwrap_or_default();
233
234 for (i, line) in lines.iter().enumerate() {
235 match line.get("patchline") {
236 None => {
237 errors.push(ValidationError {
238 layer: "Layer 1",
239 location: Some(format!("patcher.lines[{}]", i)),
240 message: "missing 'patchline' wrapper".to_string(),
241 });
242 }
243 Some(pl) => {
244 if let Some(source) = pl.get("source").and_then(|s| s.as_array()) {
246 if let Some(src_id) = source.first().and_then(|s| s.as_str()) {
247 if !box_ids.contains(src_id) {
248 errors.push(ValidationError {
249 layer: "Layer 1",
250 location: Some(format!("patcher.lines[{}].patchline", i)),
251 message: format!("source '{}' not found", src_id),
252 });
253 }
254 }
255 }
256
257 if let Some(dest) = pl.get("destination").and_then(|d| d.as_array()) {
259 if let Some(dst_id) = dest.first().and_then(|d| d.as_str()) {
260 if !box_ids.contains(dst_id) {
261 errors.push(ValidationError {
262 layer: "Layer 1",
263 location: Some(format!("patcher.lines[{}].patchline", i)),
264 message: format!("destination '{}' not found", dst_id),
265 });
266 }
267 }
268 }
269 }
270 }
271 }
272 }
273
274 (boxes_count, lines_count, errors)
275}
276
277pub fn validate_layer1(path: &Path) -> ValidationReport {
279 let content = match std::fs::read_to_string(path) {
280 Ok(c) => c,
281 Err(e) => {
282 return ValidationReport {
283 json_ok: false,
284 structure_ok: false,
285 boxes_count: 0,
286 lines_count: 0,
287 errors: vec![ValidationError {
288 layer: "Layer 1",
289 location: None,
290 message: format!("failed to read file: {}", e),
291 }],
292 warnings: vec![],
293 };
294 }
295 };
296
297 let json = match validate_json_syntax(&content) {
299 Ok(v) => v,
300 Err(e) => {
301 return ValidationReport {
302 json_ok: false,
303 structure_ok: false,
304 boxes_count: 0,
305 lines_count: 0,
306 errors: vec![e],
307 warnings: vec![],
308 };
309 }
310 };
311
312 let (boxes_count, lines_count, errors) = validate_structure(&json);
314 let structure_ok = errors.is_empty();
315
316 ValidationReport {
317 json_ok: true,
318 structure_ok,
319 boxes_count,
320 lines_count,
321 errors,
322 warnings: vec![],
323 }
324}
325
326fn generate_request_id() -> String {
332 let ts = SystemTime::now()
333 .duration_since(UNIX_EPOCH)
334 .unwrap_or_default()
335 .as_millis();
336 format!("req-{}", ts)
337}
338
339pub fn validate_via_max(
341 maxpat_path: &str,
342 port: u16,
343 timeout: Duration,
344) -> Result<MaxValidationResult, String> {
345 let abs_path = std::fs::canonicalize(maxpat_path)
347 .map_err(|e| format!("failed to resolve path '{}': {}", maxpat_path, e))?;
348 let abs_path_str = abs_path.display().to_string();
349
350 let response_port = port + 1;
352 let recv_socket = UdpSocket::bind(format!("127.0.0.1:{}", response_port))
353 .map_err(|e| format!("failed to bind UDP socket on port {}: {}", response_port, e))?;
354 recv_socket
355 .set_read_timeout(Some(timeout))
356 .map_err(|e| format!("failed to set socket timeout: {}", e))?;
357
358 let request_id = generate_request_id();
360 let request = serde_json::json!({
361 "id": request_id,
362 "cmd": "validate",
363 "path": abs_path_str,
364 });
365 let request_bytes = request.to_string().into_bytes();
366
367 let send_socket = UdpSocket::bind("127.0.0.1:0")
369 .map_err(|e| format!("failed to create send socket: {}", e))?;
370 send_socket
371 .send_to(&request_bytes, format!("127.0.0.1:{}", port))
372 .map_err(|e| format!("failed to send UDP packet to port {}: {}", port, e))?;
373
374 let mut buf = [0u8; 65535];
376 let (len, _addr) = recv_socket.recv_from(&mut buf).map_err(|e| {
377 if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut {
378 format!(
379 "timeout waiting for Max validator response ({}s). Is flutmax-validator.maxpat open?",
380 timeout.as_secs()
381 )
382 } else {
383 format!("failed to receive UDP response: {}", e)
384 }
385 })?;
386
387 let response_str = std::str::from_utf8(&buf[..len])
388 .map_err(|e| format!("invalid UTF-8 in response: {}", e))?;
389
390 let response: Value = serde_json::from_str(response_str)
392 .map_err(|e| format!("invalid JSON in response: {}", e))?;
393
394 if let Some(resp_id) = response.get("id").and_then(|v| v.as_str()) {
396 if resp_id != request_id {
397 return Err(format!(
398 "response ID mismatch: expected '{}', got '{}'",
399 request_id, resp_id
400 ));
401 }
402 }
403
404 let status = response
406 .get("status")
407 .and_then(|v| v.as_str())
408 .unwrap_or("unknown")
409 .to_string();
410
411 let boxes_checked = response
412 .get("boxes_checked")
413 .or_else(|| response.get("objects_checked"))
414 .and_then(|v| v.as_u64())
415 .unwrap_or(0) as usize;
416
417 let lines_checked = response
418 .get("lines_checked")
419 .and_then(|v| v.as_u64())
420 .unwrap_or(0) as usize;
421
422 let mut errors = Vec::new();
423 if let Some(err_arr) = response.get("errors").and_then(|v| v.as_array()) {
424 for err in err_arr {
425 errors.push(MaxValidationError {
426 error_type: err
427 .get("type")
428 .and_then(|v| v.as_str())
429 .unwrap_or("unknown")
430 .to_string(),
431 box_id: err.get("box_id").and_then(|v| v.as_str()).map(String::from),
432 message: err
433 .get("message")
434 .and_then(|v| v.as_str())
435 .unwrap_or("")
436 .to_string(),
437 });
438 }
439 }
440
441 let mut warnings = Vec::new();
442 if let Some(warn_arr) = response.get("warnings").and_then(|v| v.as_array()) {
443 for w in warn_arr {
444 if let Some(s) = w.as_str() {
445 warnings.push(s.to_string());
446 }
447 }
448 }
449
450 Ok(MaxValidationResult {
451 status,
452 errors,
453 warnings,
454 boxes_checked,
455 lines_checked,
456 })
457}
458
459fn print_header(path: &str) {
464 eprintln!();
465 eprintln!("=== flutmax validate: {} ===", path);
466}
467
468fn print_layer1_report(report: &ValidationReport) {
469 if report.json_ok {
471 eprintln!("[Layer 1] JSON syntax : OK");
472 } else {
473 eprintln!("[Layer 1] JSON syntax : ERROR");
474 for e in &report.errors {
475 if e.message.contains("JSON parse error") {
476 if let Some(ref loc) = e.location {
477 eprintln!(" - {}: {}", loc, e.message);
478 } else {
479 eprintln!(" - {}", e.message);
480 }
481 }
482 }
483 return; }
485
486 if report.structure_ok {
488 eprintln!(
489 "[Layer 1] Structure : OK ({} boxes, {} lines)",
490 report.boxes_count, report.lines_count
491 );
492 } else {
493 eprintln!("[Layer 1] Structure : ERROR");
494 for e in &report.errors {
495 if let Some(ref loc) = e.location {
496 eprintln!(" - {}: {}", loc, e.message);
497 } else {
498 eprintln!(" - {}", e.message);
499 }
500 }
501 }
502}
503
504fn print_layer2_result(result: &Result<MaxValidationResult, String>) {
505 match result {
506 Ok(r) => {
507 if r.errors.is_empty() {
508 eprintln!(
509 "[Layer 2] Max runtime : OK ({} boxes checked)",
510 r.boxes_checked
511 );
512 } else {
513 eprintln!("[Layer 2] Max runtime : ERROR");
514 for e in &r.errors {
515 if let Some(ref box_id) = e.box_id {
516 eprintln!(" - [{}] {}: {}", e.error_type, box_id, e.message);
517 } else {
518 eprintln!(" - [{}] {}", e.error_type, e.message);
519 }
520 }
521 }
522 for w in &r.warnings {
523 eprintln!(" warning: {}", w);
524 }
525 }
526 Err(msg) => {
527 eprintln!("[Layer 2] Max runtime : SKIP ({})", msg);
528 }
529 }
530}
531
532fn print_layer2_skip(port: u16) {
533 eprintln!(
534 "[Layer 2] Max runtime : SKIP (validator not running on port {})",
535 port
536 );
537}
538
539pub fn run(args: &[String]) -> i32 {
545 let opts = match parse_validate_args(args) {
546 Ok(o) => o,
547 Err(msg) if msg.is_empty() => {
548 print_validate_usage();
549 return 0;
550 }
551 Err(msg) => {
552 eprintln!("error: {}", msg);
553 eprintln!();
554 print_validate_usage();
555 return 1;
556 }
557 };
558
559 let input = &opts.input_path;
560
561 let maxpat_path: String;
563
564 if input.ends_with(".flutmax") {
565 let source = match std::fs::read_to_string(input) {
567 Ok(s) => s,
568 Err(e) => {
569 eprintln!("error: failed to read '{}': {}", input, e);
570 return 1;
571 }
572 };
573
574 let json = match crate::compile(&source) {
575 Ok(j) => j,
576 Err(e) => {
577 eprintln!("error: compilation failed: {}", e);
578 return 1;
579 }
580 };
581
582 let temp_path = std::env::temp_dir().join("flutmax_validate_temp.maxpat");
584 if let Err(e) = std::fs::write(&temp_path, &json) {
585 eprintln!(
586 "error: failed to write temp file '{}': {}",
587 temp_path.display(),
588 e
589 );
590 return 1;
591 }
592 eprintln!("compiled {} -> {}", input, temp_path.display());
593 maxpat_path = temp_path.display().to_string();
594 } else if input.ends_with(".maxpat") {
595 maxpat_path = input.clone();
596 } else {
597 eprintln!(
598 "error: unsupported file extension. Expected .flutmax or .maxpat, got '{}'",
599 input
600 );
601 return 1;
602 }
603
604 print_header(&opts.input_path);
605
606 let mut total_errors = 0usize;
607 let mut total_warnings = 0usize;
608
609 let run_layer1 = !opts.max_only;
611 let run_layer2 = !opts.ci_only;
612
613 let layer1_report = if run_layer1 {
615 let report = validate_layer1(Path::new(&maxpat_path));
616 print_layer1_report(&report);
617 total_errors += report.errors.len();
618 total_warnings += report.warnings.len();
619 Some(report)
620 } else {
621 None
622 };
623
624 let layer1_json_ok = layer1_report.as_ref().map(|r| r.json_ok).unwrap_or(true);
626
627 if run_layer2 {
629 if !layer1_json_ok {
630 eprintln!("[Layer 2] Max runtime : SKIP (JSON invalid)");
631 } else {
632 let result = validate_via_max(&maxpat_path, opts.port, opts.timeout);
634 match &result {
635 Ok(r) => {
636 total_errors += r.errors.len();
637 total_warnings += r.warnings.len();
638 print_layer2_result(&result);
639 }
640 Err(_) => {
641 print_layer2_skip(opts.port);
642 }
643 }
644 }
645 }
646
647 eprintln!();
649 if total_errors == 0 && total_warnings == 0 {
650 eprintln!("Summary: 0 errors, 0 warnings");
651 } else {
652 eprintln!(
653 "Summary: {} error{}, {} warning{}",
654 total_errors,
655 if total_errors == 1 { "" } else { "s" },
656 total_warnings,
657 if total_warnings == 1 { "" } else { "s" },
658 );
659 }
660
661 if input.ends_with(".flutmax") {
663 let temp_path = std::env::temp_dir().join("flutmax_validate_temp.maxpat");
664 let _ = std::fs::remove_file(temp_path);
665 }
666
667 if total_errors > 0 {
668 1
669 } else {
670 0
671 }
672}
673
674fn print_validate_usage() {
675 eprintln!("flutmax validate - validate .maxpat files");
676 eprintln!();
677 eprintln!("USAGE:");
678 eprintln!(" flutmax validate [options] <file.maxpat>");
679 eprintln!(" flutmax validate [options] <file.flutmax> (compiles first, then validates)");
680 eprintln!();
681 eprintln!("OPTIONS:");
682 eprintln!(" --ci Layer 1 only (static checks, no Max required)");
683 eprintln!(" --max Layer 2 only (Node for Max runtime check)");
684 eprintln!(" --full Layer 1 + Layer 2 (default)");
685 eprintln!(" --port <N> UDP port for Node for Max (default: 7401)");
686 eprintln!(" --timeout <S> Timeout in seconds for Max response (default: 10)");
687 eprintln!(" -h, --help Print help information");
688}
689
690#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn test_parse_args_basic() {
700 let args = vec!["test.maxpat".to_string()];
701 let opts = parse_validate_args(&args).unwrap();
702 assert_eq!(opts.input_path, "test.maxpat");
703 assert!(!opts.ci_only);
704 assert!(!opts.max_only);
705 assert!(!opts.full);
706 assert_eq!(opts.port, 7401);
707 assert_eq!(opts.timeout, Duration::from_secs(10));
708 }
709
710 #[test]
711 fn test_parse_args_ci() {
712 let args = vec!["--ci".to_string(), "test.maxpat".to_string()];
713 let opts = parse_validate_args(&args).unwrap();
714 assert!(opts.ci_only);
715 assert_eq!(opts.input_path, "test.maxpat");
716 }
717
718 #[test]
719 fn test_parse_args_max_with_port() {
720 let args = vec![
721 "--max".to_string(),
722 "--port".to_string(),
723 "8000".to_string(),
724 "--timeout".to_string(),
725 "30".to_string(),
726 "output.maxpat".to_string(),
727 ];
728 let opts = parse_validate_args(&args).unwrap();
729 assert!(opts.max_only);
730 assert_eq!(opts.port, 8000);
731 assert_eq!(opts.timeout, Duration::from_secs(30));
732 assert_eq!(opts.input_path, "output.maxpat");
733 }
734
735 #[test]
736 fn test_parse_args_mutually_exclusive() {
737 let args = vec![
738 "--ci".to_string(),
739 "--max".to_string(),
740 "test.maxpat".to_string(),
741 ];
742 let result = parse_validate_args(&args);
743 assert!(result.is_err());
744 assert!(result.unwrap_err().contains("mutually exclusive"));
745 }
746
747 #[test]
748 fn test_parse_args_missing_input() {
749 let args: Vec<String> = vec!["--ci".to_string()];
750 let result = parse_validate_args(&args);
751 assert!(result.is_err());
752 assert!(result.unwrap_err().contains("missing input"));
753 }
754
755 #[test]
756 fn test_validate_json_syntax_valid() {
757 let content = r#"{"patcher": {"boxes": [], "lines": [], "fileversion": 1}}"#;
758 let result = validate_json_syntax(content);
759 assert!(result.is_ok());
760 }
761
762 #[test]
763 fn test_validate_json_syntax_invalid() {
764 let content = r#"{"patcher": {"boxes": [}}"#;
765 let result = validate_json_syntax(content);
766 assert!(result.is_err());
767 }
768
769 #[test]
770 fn test_validate_structure_valid() {
771 let json: Value = serde_json::from_str(
772 r#"{
773 "patcher": {
774 "fileversion": 1,
775 "boxes": [
776 {
777 "box": {
778 "id": "obj-1",
779 "maxclass": "newobj",
780 "numinlets": 1,
781 "numoutlets": 1,
782 "patching_rect": [100, 100, 50, 22]
783 }
784 }
785 ],
786 "lines": []
787 }
788 }"#,
789 )
790 .unwrap();
791 let (boxes, lines, errors) = validate_structure(&json);
792 assert_eq!(boxes, 1);
793 assert_eq!(lines, 0);
794 assert!(errors.is_empty());
795 }
796
797 #[test]
798 fn test_validate_structure_missing_patcher() {
799 let json: Value = serde_json::from_str(r#"{"not_patcher": {}}"#).unwrap();
800 let (_, _, errors) = validate_structure(&json);
801 assert_eq!(errors.len(), 1);
802 assert!(errors[0].message.contains("patcher"));
803 }
804
805 #[test]
806 fn test_validate_structure_missing_box_id() {
807 let json: Value = serde_json::from_str(
808 r#"{
809 "patcher": {
810 "fileversion": 1,
811 "boxes": [
812 {
813 "box": {
814 "maxclass": "newobj",
815 "numinlets": 1,
816 "numoutlets": 1,
817 "patching_rect": [100, 100, 50, 22]
818 }
819 }
820 ],
821 "lines": []
822 }
823 }"#,
824 )
825 .unwrap();
826 let (_, _, errors) = validate_structure(&json);
827 assert_eq!(errors.len(), 1);
828 assert!(errors[0].message.contains("id"));
829 }
830
831 #[test]
832 fn test_validate_structure_dangling_source() {
833 let json: Value = serde_json::from_str(
834 r#"{
835 "patcher": {
836 "fileversion": 1,
837 "boxes": [
838 {
839 "box": {
840 "id": "obj-1",
841 "maxclass": "newobj",
842 "numinlets": 1,
843 "numoutlets": 1,
844 "patching_rect": [100, 100, 50, 22]
845 }
846 }
847 ],
848 "lines": [
849 {
850 "patchline": {
851 "source": ["obj-99", 0],
852 "destination": ["obj-1", 0]
853 }
854 }
855 ]
856 }
857 }"#,
858 )
859 .unwrap();
860 let (_, _, errors) = validate_structure(&json);
861 assert!(!errors.is_empty());
862 assert!(errors.iter().any(|e| e.message.contains("obj-99")));
863 }
864
865 #[test]
866 fn test_generate_request_id() {
867 let id1 = generate_request_id();
868 assert!(id1.starts_with("req-"));
869 assert!(id1.len() > 4);
870 }
871}