Skip to main content

flutmax_cli/
validate.rs

1use serde_json::Value;
2use std::net::UdpSocket;
3use std::path::Path;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6// ---------------------------------------------------------------------------
7// Types
8// ---------------------------------------------------------------------------
9
10/// Validation error found by Layer 1 or Layer 2.
11#[derive(Debug)]
12pub struct ValidationError {
13    pub layer: &'static str,
14    pub location: Option<String>,
15    pub message: String,
16}
17
18/// Layer 1 static validation report.
19#[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/// Layer 2 Max runtime validation result.
30#[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/// Individual error from Max runtime validation.
40#[derive(Debug)]
41pub struct MaxValidationError {
42    pub error_type: String,
43    pub box_id: Option<String>,
44    pub message: String,
45}
46
47/// Options for the validate subcommand.
48#[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
71// ---------------------------------------------------------------------------
72// Argument parsing
73// ---------------------------------------------------------------------------
74
75pub 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()); // signals help request
114            }
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    // Validate mutually exclusive flags
133    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
144// ---------------------------------------------------------------------------
145// Layer 1: Static validation
146// ---------------------------------------------------------------------------
147
148/// Validate JSON syntax of a .maxpat file.
149fn 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
157/// Validate .maxpat structure (required fields, box wrappers, etc.).
158fn 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    // Required patcher fields
176    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    // Validate boxes array
187    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    // Validate lines array and cross-reference with boxes
214    if let Some(lines) = patcher.get("lines").and_then(|l| l.as_array()) {
215        lines_count = lines.len();
216
217        // Collect all box IDs for reference checking
218        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                    // Check source reference exists
245                    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                    // Check destination reference exists
258                    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
277/// Run Layer 1 static validation on a .maxpat file.
278pub 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    // JSON syntax check
298    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    // Structure check
313    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
326// ---------------------------------------------------------------------------
327// Layer 2: Max runtime validation via UDP
328// ---------------------------------------------------------------------------
329
330/// Generate a simple request ID from the current timestamp.
331fn 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
339/// Send a validation request to Node for Max via UDP and wait for response.
340pub fn validate_via_max(
341    maxpat_path: &str,
342    port: u16,
343    timeout: Duration,
344) -> Result<MaxValidationResult, String> {
345    // Resolve to absolute path
346    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    // Bind a socket on port+1 to receive the response
351    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    // Build request JSON
359    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    // Send request to Node for Max
368    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    // Wait for response
375    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    // Parse response JSON
391    let response: Value = serde_json::from_str(response_str)
392        .map_err(|e| format!("invalid JSON in response: {}", e))?;
393
394    // Check that response ID matches
395    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    // Extract result fields
405    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
459// ---------------------------------------------------------------------------
460// Output formatting
461// ---------------------------------------------------------------------------
462
463fn print_header(path: &str) {
464    eprintln!();
465    eprintln!("=== flutmax validate: {} ===", path);
466}
467
468fn print_layer1_report(report: &ValidationReport) {
469    // JSON syntax
470    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; // No point checking structure if JSON is invalid
484    }
485
486    // Structure
487    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
539// ---------------------------------------------------------------------------
540// Main entry point
541// ---------------------------------------------------------------------------
542
543/// Run the validate subcommand. Returns the process exit code.
544pub 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    // If input is .flutmax, compile to a temp file first
562    let maxpat_path: String;
563
564    if input.ends_with(".flutmax") {
565        // Compile .flutmax to a temporary .maxpat
566        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        // Write to a temp file in the system temp directory
583        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    // Determine which layers to run
610    let run_layer1 = !opts.max_only;
611    let run_layer2 = !opts.ci_only;
612
613    // Layer 1: Static validation
614    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    // Don't run Layer 2 if Layer 1 JSON failed
625    let layer1_json_ok = layer1_report.as_ref().map(|r| r.json_ok).unwrap_or(true);
626
627    // Layer 2: Max runtime validation
628    if run_layer2 {
629        if !layer1_json_ok {
630            eprintln!("[Layer 2] Max runtime      : SKIP (JSON invalid)");
631        } else {
632            // Try to connect; gracefully handle failure
633            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    // Summary
648    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    // Clean up temp file if we created one
662    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// ---------------------------------------------------------------------------
691// Tests
692// ---------------------------------------------------------------------------
693
694#[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}