1use std::path::Path;
2
3use quick_xml::de::from_str;
4use serde::Deserialize;
5
6use crate::{ArgDef, InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType};
7
8#[derive(Debug)]
10pub enum ParseError {
11 Xml(quick_xml::DeError),
12 Io(std::io::Error),
13 MissingName,
15}
16
17impl std::fmt::Display for ParseError {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 ParseError::Xml(e) => write!(f, "XML parse error: {}", e),
21 ParseError::Io(e) => write!(f, "IO error: {}", e),
22 ParseError::MissingName => write!(f, "Missing 'name' attribute on c74object"),
23 }
24 }
25}
26
27impl std::error::Error for ParseError {}
28
29impl From<quick_xml::DeError> for ParseError {
30 fn from(e: quick_xml::DeError) -> Self {
31 ParseError::Xml(e)
32 }
33}
34
35impl From<std::io::Error> for ParseError {
36 fn from(e: std::io::Error) -> Self {
37 ParseError::Io(e)
38 }
39}
40
41#[derive(Debug, Deserialize)]
45#[serde(rename = "c74object")]
46struct XmlC74Object {
47 #[serde(rename = "@name")]
48 name: Option<String>,
49 #[serde(rename = "@module")]
50 module: Option<String>,
51 #[serde(rename = "@category")]
52 category: Option<String>,
53 digest: Option<XmlDigest>,
54 inletlist: Option<XmlInletList>,
55 outletlist: Option<XmlOutletList>,
56 objarglist: Option<XmlObjArgList>,
57}
58
59#[derive(Debug, Deserialize)]
60struct XmlInletList {
61 #[serde(rename = "inlet", default)]
62 inlets: Vec<XmlInlet>,
63}
64
65#[derive(Debug, Deserialize)]
66struct XmlInlet {
67 #[serde(rename = "@id")]
68 id: Option<u32>,
69 #[serde(rename = "@type")]
70 inlet_type: Option<String>,
71 digest: Option<XmlDigest>,
72}
73
74#[derive(Debug, Deserialize)]
75struct XmlOutletList {
76 #[serde(rename = "outlet", default)]
77 outlets: Vec<XmlOutlet>,
78}
79
80#[derive(Debug, Deserialize)]
81struct XmlOutlet {
82 #[serde(rename = "@id")]
83 id: Option<u32>,
84 #[serde(rename = "@type")]
85 outlet_type: Option<String>,
86 digest: Option<XmlDigest>,
87}
88
89#[derive(Debug, Deserialize)]
90struct XmlObjArgList {
91 #[serde(rename = "objarg", default)]
92 args: Vec<XmlObjArg>,
93}
94
95#[derive(Debug, Deserialize)]
96struct XmlObjArg {
97 #[serde(rename = "@name")]
98 name: Option<String>,
99 #[serde(rename = "@optional")]
100 optional: Option<String>,
101 #[serde(rename = "@type")]
102 arg_type: Option<String>,
103}
104
105#[derive(Debug, Deserialize)]
106struct XmlDigest {
107 #[serde(rename = "$text")]
108 text: Option<String>,
109}
110
111fn has_variable_ports(args: &[XmlObjArg], ports: &[XmlInlet]) -> bool {
115 let has_dynamic_type = ports
117 .iter()
118 .any(|p| matches!(p.inlet_type.as_deref(), Some("INLET_TYPE")));
119
120 if has_dynamic_type && !args.is_empty() {
123 return true;
124 }
125
126 false
127}
128
129fn has_variable_outlet_ports(args: &[XmlObjArg], ports: &[XmlOutlet]) -> bool {
130 let has_dynamic_type = ports
131 .iter()
132 .any(|p| matches!(p.outlet_type.as_deref(), Some("OUTLET_TYPE")));
133
134 if has_dynamic_type && !args.is_empty() {
135 return true;
136 }
137
138 false
139}
140
141fn convert_inlet(inlet: &XmlInlet, index: usize) -> PortDef {
142 let type_str = inlet.inlet_type.as_deref().unwrap_or("");
143 let digest_text = inlet
144 .digest
145 .as_ref()
146 .and_then(|d| d.text.as_deref())
147 .unwrap_or("")
148 .trim()
149 .to_string();
150
151 PortDef {
152 id: inlet.id.unwrap_or(index as u32),
153 port_type: PortType::from_xml_type(type_str),
154 is_hot: inlet.id.unwrap_or(index as u32) == 0,
155 description: digest_text,
156 }
157}
158
159fn convert_outlet(outlet: &XmlOutlet, index: usize) -> PortDef {
160 let type_str = outlet.outlet_type.as_deref().unwrap_or("");
161 let digest_text = outlet
162 .digest
163 .as_ref()
164 .and_then(|d| d.text.as_deref())
165 .unwrap_or("")
166 .trim()
167 .to_string();
168
169 PortDef {
170 id: outlet.id.unwrap_or(index as u32),
171 port_type: PortType::from_xml_type(type_str),
172 is_hot: false,
173 description: digest_text,
174 }
175}
176
177fn convert_arg(arg: &XmlObjArg) -> ArgDef {
178 ArgDef {
179 name: arg.name.clone().unwrap_or_default(),
180 arg_type: arg.arg_type.clone().unwrap_or_default(),
181 optional: arg.optional.as_deref() == Some("1"),
182 }
183}
184
185pub fn parse_maxref(xml_content: &str) -> Result<ObjectDef, ParseError> {
187 let obj: XmlC74Object = from_str(xml_content)?;
188
189 let name = obj.name.ok_or(ParseError::MissingName)?;
190 let module = Module::parse(obj.module.as_deref().unwrap_or("max"));
191 let category = obj.category.unwrap_or_default();
192 let digest = obj
193 .digest
194 .and_then(|d| d.text)
195 .unwrap_or_default()
196 .trim()
197 .to_string();
198
199 let xml_inlets = obj.inletlist.map(|il| il.inlets).unwrap_or_default();
200 let xml_outlets = obj.outletlist.map(|ol| ol.outlets).unwrap_or_default();
201 let xml_args = obj.objarglist.map(|al| al.args).unwrap_or_default();
202
203 let inlet_defs: Vec<PortDef> = xml_inlets
204 .iter()
205 .enumerate()
206 .map(|(i, inlet)| convert_inlet(inlet, i))
207 .collect();
208
209 let outlet_defs: Vec<PortDef> = xml_outlets
210 .iter()
211 .enumerate()
212 .map(|(i, outlet)| convert_outlet(outlet, i))
213 .collect();
214
215 let args: Vec<ArgDef> = xml_args.iter().map(convert_arg).collect();
216
217 let inlets = if has_variable_ports(&xml_args, &xml_inlets) {
218 InletSpec::Variable {
219 min_inlets: if inlet_defs.is_empty() { 0 } else { 1 },
220 defaults: inlet_defs,
221 }
222 } else {
223 InletSpec::Fixed(inlet_defs)
224 };
225
226 let outlets = if has_variable_outlet_ports(&xml_args, &xml_outlets) {
227 OutletSpec::Variable {
228 min_outlets: if outlet_defs.is_empty() { 0 } else { 1 },
229 defaults: outlet_defs,
230 }
231 } else {
232 OutletSpec::Fixed(outlet_defs)
233 };
234
235 Ok(ObjectDef {
236 name,
237 module,
238 category,
239 digest,
240 inlets,
241 outlets,
242 args,
243 })
244}
245
246pub fn load_directory(dir: &Path) -> Result<(ObjectDb, usize), ParseError> {
249 let mut db = ObjectDb::new();
250 let mut error_count = 0;
251
252 if !dir.is_dir() {
253 return Err(ParseError::Io(std::io::Error::new(
254 std::io::ErrorKind::NotFound,
255 format!("Directory not found: {:?}", dir),
256 )));
257 }
258
259 let entries = std::fs::read_dir(dir)?;
260 for entry in entries {
261 let entry = entry?;
262 let path = entry.path();
263
264 if path.extension().and_then(|e| e.to_str()) != Some("xml") {
265 continue;
266 }
267
268 let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
269
270 if !file_name.ends_with(".maxref.xml") {
271 continue;
272 }
273
274 let content = match std::fs::read_to_string(&path) {
275 Ok(c) => c,
276 Err(_) => {
277 error_count += 1;
278 continue;
279 }
280 };
281
282 match parse_maxref(&content) {
283 Ok(def) => {
284 db.insert(def);
285 }
286 Err(_) => {
287 error_count += 1;
288 }
289 }
290 }
291
292 Ok((db, error_count))
293}
294
295pub fn load_directory_recursive(dir: &Path) -> Result<(ObjectDb, usize), ParseError> {
300 let mut db = ObjectDb::new();
301 let mut error_count = 0;
302 load_recursive_inner(dir, &mut db, &mut error_count)?;
303 Ok((db, error_count))
304}
305
306fn load_recursive_inner(
307 dir: &Path,
308 db: &mut ObjectDb,
309 error_count: &mut usize,
310) -> Result<(), ParseError> {
311 if !dir.is_dir() {
312 return Ok(());
313 }
314
315 let entries = std::fs::read_dir(dir).map_err(ParseError::Io)?;
316 for entry in entries {
317 let entry = entry.map_err(ParseError::Io)?;
318 let path = entry.path();
319
320 if path.is_dir() {
321 load_recursive_inner(&path, db, error_count)?;
322 } else if path.extension().and_then(|e| e.to_str()) == Some("xml") {
323 let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
324 if !file_name.ends_with(".maxref.xml") {
325 continue;
326 }
327
328 match std::fs::read_to_string(&path) {
329 Ok(content) => match parse_maxref(&content) {
330 Ok(def) => {
331 db.insert(def);
332 }
333 Err(_) => {
334 *error_count += 1;
335 }
336 },
337 Err(_) => {
338 *error_count += 1;
339 }
340 }
341 }
342 }
343
344 Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 const CYCLE_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
352<c74object name="cycle~" module="msp" category="MSP Synthesis">
353 <digest>Sinusoidal oscillator</digest>
354 <description>Use the cycle~ object to generate a periodic waveform.</description>
355 <inletlist>
356 <inlet id="0" type="signal/float">
357 <digest>Frequency</digest>
358 <description>TEXT_HERE</description>
359 </inlet>
360 <inlet id="1" type="signal/float">
361 <digest>Phase (0-1)</digest>
362 <description>TEXT_HERE</description>
363 </inlet>
364 </inletlist>
365 <outletlist>
366 <outlet id="0" type="signal">
367 <digest>Output</digest>
368 <description>TEXT_HERE</description>
369 </outlet>
370 </outletlist>
371 <objarglist>
372 <objarg name="frequency" optional="1" units="hz" type="number">
373 <digest>Oscillator frequency (initial)</digest>
374 </objarg>
375 <objarg name="buffer-name" optional="1" type="symbol">
376 <digest>Buffer name</digest>
377 </objarg>
378 </objarglist>
379</c74object>"#;
380
381 const BIQUAD_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
382<c74object name="biquad~" module="msp" category="MSP Filters">
383 <digest>Two-pole, two-zero filter</digest>
384 <description>biquad~ implements a two-pole, two-zero filter.</description>
385 <inletlist>
386 <inlet id="0" type="signal">
387 <digest>Input</digest>
388 <description>TEXT_HERE</description>
389 </inlet>
390 <inlet id="1" type="signal/float">
391 <digest>Input Gain (Filter coefficient a0)</digest>
392 <description>TEXT_HERE</description>
393 </inlet>
394 <inlet id="2" type="signal/float">
395 <digest>Filter coefficient a1</digest>
396 <description>TEXT_HERE</description>
397 </inlet>
398 <inlet id="3" type="signal/float">
399 <digest>Filter coefficient a2</digest>
400 <description>TEXT_HERE</description>
401 </inlet>
402 <inlet id="4" type="signal/float">
403 <digest>Filter coefficient b1</digest>
404 <description>TEXT_HERE</description>
405 </inlet>
406 <inlet id="5" type="signal/float">
407 <digest>Filter coefficient b2</digest>
408 <description>TEXT_HERE</description>
409 </inlet>
410 </inletlist>
411 <outletlist>
412 <outlet id="0" type="signal">
413 <digest>Output</digest>
414 <description>TEXT_HERE</description>
415 </outlet>
416 </outletlist>
417 <objarglist>
418 <objarg name="a0" optional="0" type="float">
419 <digest>a0 coefficient initial value</digest>
420 </objarg>
421 <objarg name="a1" optional="0" type="float">
422 <digest>a1 coefficient initial value</digest>
423 </objarg>
424 <objarg name="a2" optional="0" type="float">
425 <digest>a2 coefficient initial value</digest>
426 </objarg>
427 <objarg name="b1" optional="0" type="float">
428 <digest>b1 coefficient initial value</digest>
429 </objarg>
430 <objarg name="b2" optional="0" type="float">
431 <digest>b2 coefficient initial value</digest>
432 </objarg>
433 </objarglist>
434</c74object>"#;
435
436 const TRIGGER_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
437<c74object name="trigger" module="max" category="Control, Right-to-Left">
438 <digest>Send input to many places</digest>
439 <description>Outputs any input received in order from right to left.</description>
440 <inletlist>
441 <inlet id="0" type="INLET_TYPE">
442 <digest>Message to be Fanned to Multiple Outputs</digest>
443 <description>TEXT_HERE</description>
444 </inlet>
445 </inletlist>
446 <outletlist>
447 <outlet id="0" type="OUTLET_TYPE">
448 <digest>Output Order 2 (int)</digest>
449 <description>TEXT_HERE</description>
450 </outlet>
451 <outlet id="1" type="OUTLET_TYPE">
452 <digest>Output Order 1 (int)</digest>
453 <description>TEXT_HERE</description>
454 </outlet>
455 </outletlist>
456 <objarglist>
457 <objarg name="formats" optional="1" type="symbol">
458 <digest>Output types</digest>
459 <description>The number of arguments determines the number of outlets.</description>
460 </objarg>
461 </objarglist>
462</c74object>"#;
463
464 const PACK_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
465<c74object name="pack" module="max" category="Lists">
466 <digest>Create a list</digest>
467 <description>Combine items into an output list.</description>
468 <inletlist>
469 <inlet id="0" type="INLET_TYPE">
470 <digest>value for the first list element, causes output</digest>
471 <description></description>
472 </inlet>
473 <inlet id="1" type="INLET_TYPE">
474 <digest>value for the second list element</digest>
475 <description></description>
476 </inlet>
477 </inletlist>
478 <outletlist>
479 <outlet id="0" type="OUTLET_TYPE">
480 <digest>Output list</digest>
481 <description></description>
482 </outlet>
483 </outletlist>
484 <objarglist>
485 <objarg name="list-elements" optional="1" type="any">
486 <digest>List elements</digest>
487 <description>The number of inlets is determined by the number of arguments.</description>
488 </objarg>
489 </objarglist>
490</c74object>"#;
491
492 const SELECTOR_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
493<c74object name="selector~" module="msp" category="MSP Routing">
494 <digest>Assign one of several inputs to an outlet</digest>
495 <description>Use the selector~ object to choose between one of several input signals.</description>
496 <inletlist>
497 <inlet id="0" type="int/signal">
498 <digest>int/signal Turns Input Off or Routes to Output</digest>
499 <description>TEXT_HERE</description>
500 </inlet>
501 <inlet id="1" type="signal">
502 <digest>(signal) Input</digest>
503 <description>TEXT_HERE</description>
504 </inlet>
505 </inletlist>
506 <outletlist>
507 <outlet id="0" type="signal">
508 <digest>(signal) Output</digest>
509 <description>TEXT_HERE</description>
510 </outlet>
511 </outletlist>
512 <objarglist>
513 <objarg name="number-of-inputs" optional="1" type="int">
514 <digest>Number of inputs</digest>
515 </objarg>
516 <objarg name="initially-open-inlet" optional="1" type="int">
517 <digest>Initial input selected</digest>
518 </objarg>
519 </objarglist>
520</c74object>"#;
521
522 #[test]
525 fn test_parse_cycle() {
526 let def = parse_maxref(CYCLE_XML).unwrap();
527 assert_eq!(def.name, "cycle~");
528 assert_eq!(def.module, Module::Msp);
529 assert_eq!(def.category, "MSP Synthesis");
530 assert_eq!(def.digest, "Sinusoidal oscillator");
531
532 assert!(!def.has_variable_inlets());
534 assert_eq!(def.default_inlet_count(), 2);
535
536 if let InletSpec::Fixed(ref inlets) = def.inlets {
537 assert_eq!(inlets[0].id, 0);
538 assert_eq!(inlets[0].port_type, PortType::SignalFloat);
539 assert!(inlets[0].is_hot);
540 assert_eq!(inlets[0].description, "Frequency");
541
542 assert_eq!(inlets[1].id, 1);
543 assert_eq!(inlets[1].port_type, PortType::SignalFloat);
544 assert!(!inlets[1].is_hot);
545 assert_eq!(inlets[1].description, "Phase (0-1)");
546 } else {
547 panic!("Expected Fixed inlets for cycle~");
548 }
549
550 assert!(!def.has_variable_outlets());
552 assert_eq!(def.default_outlet_count(), 1);
553
554 if let OutletSpec::Fixed(ref outlets) = def.outlets {
555 assert_eq!(outlets[0].port_type, PortType::Signal);
556 } else {
557 panic!("Expected Fixed outlets for cycle~");
558 }
559
560 assert_eq!(def.args.len(), 2);
562 assert_eq!(def.args[0].name, "frequency");
563 assert!(def.args[0].optional);
564 }
565
566 #[test]
569 fn test_parse_biquad() {
570 let def = parse_maxref(BIQUAD_XML).unwrap();
571 assert_eq!(def.name, "biquad~");
572 assert_eq!(def.module, Module::Msp);
573 assert!(!def.has_variable_inlets());
574 assert_eq!(def.default_inlet_count(), 6);
575
576 if let InletSpec::Fixed(ref inlets) = def.inlets {
577 assert_eq!(inlets[0].port_type, PortType::Signal);
579 assert!(inlets[0].is_hot);
580 for i in 1..6 {
582 assert_eq!(inlets[i].port_type, PortType::SignalFloat);
583 assert!(!inlets[i].is_hot);
584 }
585 } else {
586 panic!("Expected Fixed inlets for biquad~");
587 }
588
589 assert_eq!(def.default_outlet_count(), 1);
590 assert_eq!(def.args.len(), 5);
591 assert!(!def.args[0].optional); }
593
594 #[test]
597 fn test_parse_trigger() {
598 let def = parse_maxref(TRIGGER_XML).unwrap();
599 assert_eq!(def.name, "trigger");
600 assert_eq!(def.module, Module::Max);
601
602 assert!(def.has_variable_outlets());
604 assert_eq!(def.default_outlet_count(), 2);
605
606 assert!(def.has_variable_inlets());
608 if let InletSpec::Variable { ref defaults, .. } = def.inlets {
609 assert_eq!(defaults.len(), 1);
610 assert_eq!(defaults[0].port_type, PortType::Dynamic);
611 assert!(defaults[0].is_hot);
612 } else {
613 panic!("Expected Variable inlets for trigger");
614 }
615
616 if let OutletSpec::Variable { ref defaults, .. } = def.outlets {
617 assert_eq!(defaults.len(), 2);
618 assert_eq!(defaults[0].port_type, PortType::Dynamic);
619 assert_eq!(defaults[1].port_type, PortType::Dynamic);
620 } else {
621 panic!("Expected Variable outlets for trigger");
622 }
623 }
624
625 #[test]
628 fn test_parse_pack() {
629 let def = parse_maxref(PACK_XML).unwrap();
630 assert_eq!(def.name, "pack");
631 assert_eq!(def.module, Module::Max);
632
633 assert!(def.has_variable_inlets());
635 assert_eq!(def.default_inlet_count(), 2);
636
637 if let InletSpec::Variable {
638 ref defaults,
639 min_inlets,
640 } = def.inlets
641 {
642 assert_eq!(min_inlets, 1);
643 assert_eq!(defaults.len(), 2);
644 assert_eq!(defaults[0].port_type, PortType::Dynamic);
645 assert!(defaults[0].is_hot);
646 assert!(!defaults[1].is_hot);
647 } else {
648 panic!("Expected Variable inlets for pack");
649 }
650
651 assert!(def.has_variable_outlets());
653 }
654
655 #[test]
658 fn test_parse_selector() {
659 let def = parse_maxref(SELECTOR_XML).unwrap();
660 assert_eq!(def.name, "selector~");
661 assert_eq!(def.module, Module::Msp);
662 assert_eq!(def.category, "MSP Routing");
663
664 assert!(!def.has_variable_inlets());
666 assert_eq!(def.default_inlet_count(), 2);
667
668 if let InletSpec::Fixed(ref inlets) = def.inlets {
669 assert_eq!(inlets[0].port_type, PortType::IntSignal);
670 assert!(inlets[0].is_hot);
671 assert_eq!(inlets[1].port_type, PortType::Signal);
672 assert!(!inlets[1].is_hot);
673 } else {
674 panic!("Expected Fixed inlets for selector~");
675 }
676
677 assert!(!def.has_variable_outlets());
678 assert_eq!(def.default_outlet_count(), 1);
679 }
680
681 #[test]
684 fn test_parse_missing_name() {
685 let xml = r#"<?xml version="1.0"?>
686<c74object module="msp">
687 <digest>Test</digest>
688</c74object>"#;
689 let result = parse_maxref(xml);
690 assert!(result.is_err());
691 }
692
693 #[test]
694 fn test_parse_minimal() {
695 let xml = r#"<?xml version="1.0"?>
696<c74object name="test" module="max">
697 <digest>Minimal test</digest>
698</c74object>"#;
699 let def = parse_maxref(xml).unwrap();
700 assert_eq!(def.name, "test");
701 assert_eq!(def.default_inlet_count(), 0);
702 assert_eq!(def.default_outlet_count(), 0);
703 }
704
705 #[test]
708 fn test_parse_real_cycle_xml() {
709 let path = Path::new(
710 "/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref/cycle~.maxref.xml",
711 );
712 if !path.exists() {
713 eprintln!("Skipping test: Max.app not found");
714 return;
715 }
716
717 let content = std::fs::read_to_string(path).unwrap();
718 let def = parse_maxref(&content).unwrap();
719
720 assert_eq!(def.name, "cycle~");
721 assert_eq!(def.module, Module::Msp);
722 assert_eq!(def.default_inlet_count(), 2);
723 assert_eq!(def.default_outlet_count(), 1);
724 }
725
726 #[test]
727 fn test_parse_real_biquad_xml() {
728 let path = Path::new(
729 "/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref/biquad~.maxref.xml",
730 );
731 if !path.exists() {
732 eprintln!("Skipping test: Max.app not found");
733 return;
734 }
735
736 let content = std::fs::read_to_string(path).unwrap();
737 let def = parse_maxref(&content).unwrap();
738
739 assert_eq!(def.name, "biquad~");
740 assert_eq!(def.default_inlet_count(), 6);
741 }
742
743 #[test]
744 fn test_parse_real_trigger_xml() {
745 let path = Path::new(
746 "/Applications/Max.app/Contents/Resources/C74/docs/refpages/max-ref/trigger.maxref.xml",
747 );
748 if !path.exists() {
749 eprintln!("Skipping test: Max.app not found");
750 return;
751 }
752
753 let content = std::fs::read_to_string(path).unwrap();
754 let def = parse_maxref(&content).unwrap();
755
756 assert_eq!(def.name, "trigger");
757 assert!(def.has_variable_outlets());
758 }
759
760 #[test]
761 fn test_load_msp_ref_directory() {
762 let dir = Path::new("/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref");
763 if !dir.exists() {
764 eprintln!("Skipping test: Max.app not found");
765 return;
766 }
767
768 let (db, errors) = load_directory(dir).unwrap();
769
770 assert!(db.len() > 400, "Expected > 400 objects, got {}", db.len());
772 assert!(errors < 60, "Too many parse errors: {}", errors);
773
774 assert!(db.lookup("cycle~").is_some());
776 assert!(db.lookup("biquad~").is_some());
777 assert!(db.lookup("selector~").is_some());
778 }
779
780 #[test]
781 fn test_load_max_ref_directory() {
782 let dir = Path::new("/Applications/Max.app/Contents/Resources/C74/docs/refpages/max-ref");
783 if !dir.exists() {
784 eprintln!("Skipping test: Max.app not found");
785 return;
786 }
787
788 let (db, errors) = load_directory(dir).unwrap();
789
790 assert!(db.len() > 400, "Expected > 400 objects, got {}", db.len());
791 assert!(errors < 80, "Too many parse errors: {}", errors);
792
793 assert!(db.lookup("trigger").is_some());
794 assert!(db.lookup("pack").is_some());
795 }
796
797 #[test]
800 fn test_load_directory_recursive_on_flat_dir() {
801 let dir = Path::new("/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref");
803 if !dir.exists() {
804 eprintln!("Skipping test: Max.app not found");
805 return;
806 }
807
808 let (db_flat, errors_flat) = load_directory(dir).unwrap();
809 let (db_recursive, errors_recursive) = load_directory_recursive(dir).unwrap();
810
811 assert_eq!(
813 db_flat.len(),
814 db_recursive.len(),
815 "Flat ({}) and recursive ({}) should match on a flat directory",
816 db_flat.len(),
817 db_recursive.len()
818 );
819 assert_eq!(errors_flat, errors_recursive);
820 }
821
822 #[test]
823 fn test_load_directory_recursive_finds_subdirectories() {
824 let packages_dir = Path::new("/Applications/Max.app/Contents/Resources/C74/packages");
826 if !packages_dir.exists() {
827 eprintln!("Skipping test: Max.app packages not found");
828 return;
829 }
830
831 let gen_refpages = packages_dir.join("Gen").join("docs").join("refpages1");
833 if !gen_refpages.exists() {
834 eprintln!("Skipping test: Gen package refpages1 not found");
835 return;
836 }
837
838 let (db, _errors) = load_directory_recursive(&gen_refpages).unwrap();
839 eprintln!(
840 "load_directory_recursive on Gen/docs/refpages1: {} objects",
841 db.len()
842 );
843 assert!(
844 db.len() > 0,
845 "Expected at least 1 object from recursive scan of Gen refpages1"
846 );
847 }
848
849 #[test]
850 fn test_load_directory_recursive_nonexistent_dir() {
851 let dir = Path::new("/nonexistent/path/that/does/not/exist");
852 let (db, error_count) = load_directory_recursive(dir).unwrap();
853 assert!(db.is_empty());
854 assert_eq!(error_count, 0);
855 }
856}