Skip to main content

altium_format/ops/
schdoc.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// SPDX-FileCopyrightText: 2026 Alexander Kiselev <alex@akiselev.com>
3//
4//! Schematic document operations.
5//!
6//! High-level operations for exploring and editing Altium schematic documents (.SchDoc files).
7
8use std::collections::HashMap;
9use std::fs::File;
10use std::io::BufReader;
11use std::path::{Path, PathBuf};
12
13use serde::Serialize;
14use serde_json;
15
16use crate::ops::categorization::categorize_component;
17use crate::ops::output::*;
18use crate::ops::queries::power::{power_map, separate_power_and_ground};
19use crate::ops::util::{
20    alphanumeric_sort, count_record_types, get_component_designator, get_component_pins,
21    record_type_name, sheet_size_name,
22};
23
24use crate::dump::{fmt_coord, fmt_point};
25use crate::io::SchDoc;
26use crate::records::sch::{
27    PinElectricalType, PortIoType, PowerObjectStyle, SchComponent, SchNetLabel, SchPort,
28    SchPowerObject, SchRecord, SchWire,
29};
30use crate::tree::{RecordId, RecordTree};
31
32/// Open schematic document with String error type (for old-style functions).
33fn open_schdoc(path: &Path) -> Result<SchDoc, String> {
34    let file = File::open(path).map_err(|e| format!("Error opening file: {}", e))?;
35    SchDoc::open(BufReader::new(file)).map_err(|e| format!("Error parsing SchDoc: {:?}", e))
36}
37
38/// Open schematic document with Box<dyn Error> error type (for refactored functions).
39fn open_schdoc_boxed(path: &Path) -> Result<SchDoc, Box<dyn std::error::Error>> {
40    let file = File::open(path)?;
41    Ok(SchDoc::open(BufReader::new(file))?)
42}
43
44// ═══════════════════════════════════════════════════════════════════════════
45// CREATION COMMANDS
46// ═══════════════════════════════════════════════════════════════════════════
47
48/// Embedded blank SchDoc template.
49const BLANK_SCHDOC_TEMPLATE: &[u8] = include_bytes!("../../data/blank/Sheet1.SchDoc");
50
51/// Create a new empty SchDoc file.
52pub fn cmd_create(path: &Path, template: Option<PathBuf>) -> Result<(), String> {
53    if path.exists() {
54        return Err(format!("File already exists: {}", path.display()));
55    }
56
57    match template {
58        Some(template_path) => {
59            std::fs::copy(&template_path, path)
60                .map_err(|e| format!("Error copying template: {}", e))?;
61            println!("Created SchDoc from template: {}", path.display());
62            println!("  Template: {}", template_path.display());
63        }
64        None => {
65            std::fs::write(path, BLANK_SCHDOC_TEMPLATE)
66                .map_err(|e| format!("Error creating file: {}", e))?;
67            println!("Created empty SchDoc: {}", path.display());
68        }
69    }
70
71    let doc = open_schdoc_boxed(path)
72        .map_err(|e| format!("Error verifying SchDoc: {}", e))?;
73    println!("  Records: {}", doc.primitives.len());
74
75    Ok(())
76}
77
78// ═══════════════════════════════════════════════════════════════════════════
79// HIGH-LEVEL COMMANDS
80// ═══════════════════════════════════════════════════════════════════════════
81
82/// Complete design overview.
83pub fn cmd_overview(path: &Path) -> Result<SchDocOverview, Box<dyn std::error::Error>> {
84    let doc = open_schdoc(path)?;
85    let tree = RecordTree::from_records(doc.primitives.clone());
86    let counts = count_record_types(&doc);
87
88    let sheet_size = doc
89        .sheet_header()
90        .map(|h| sheet_size_name(h.sheet_size).to_string())
91        .unwrap_or_else(|| "Unknown".to_string());
92
93    // Collect components by category
94    let mut categories: HashMap<&'static str, Vec<(String, String, String)>> = HashMap::new();
95    for (id, record) in tree.iter() {
96        if let SchRecord::Component(c) = record {
97            let des = get_component_designator(&tree, id).unwrap_or_else(|| "<none>".to_string());
98            let category = categorize_component(&c.lib_reference, &c.component_description);
99            categories.entry(category).or_default().push((
100                des,
101                c.lib_reference.clone(),
102                c.component_description.clone(),
103            ));
104        }
105    }
106
107    // Convert to output format
108    let category_order = [
109        "Microcontroller",
110        "FPGA/CPLD",
111        "Memory",
112        "ADC",
113        "DAC",
114        "Transceiver/PHY",
115        "Clock/Oscillator",
116        "Power Supply",
117        "Amplifier",
118        "Mux/Switch",
119        "Buffer/Driver",
120        "Other IC",
121        "Transistor",
122        "Diode/Protection",
123        "LED",
124        "Capacitor",
125        "Resistor",
126        "Inductor/Ferrite",
127        "Connector",
128        "Test Point",
129    ];
130
131    let mut components_by_category = Vec::new();
132    for &category in &category_order {
133        if let Some(comps) = categories.get(category) {
134            let comp_refs: Vec<SchDocComponentRef> = comps
135                .iter()
136                .map(|(des, lib_ref, desc)| SchDocComponentRef {
137                    designator: des.clone(),
138                    lib_reference: lib_ref.clone(),
139                    description: desc.clone(),
140                })
141                .collect();
142            components_by_category.push((category.to_string(), comp_refs));
143        }
144    }
145
146    // Collect power nets using extracted query functions
147    let power_nets = power_map(&doc);
148    let (rails, grounds) = separate_power_and_ground(power_nets);
149
150    let power_architecture = PowerArchitecture {
151        power_rails: rails,
152        ground_nets: grounds,
153    };
154
155    // Collect interface ports
156    let ports: Vec<_> = doc
157        .primitives
158        .iter()
159        .filter_map(|r| {
160            if let SchRecord::Port(p) = r {
161                Some(p)
162            } else {
163                None
164            }
165        })
166        .collect();
167
168    let interfaces = if !ports.is_empty() {
169        let inputs: Vec<String> = ports
170            .iter()
171            .filter(|p| matches!(p.io_type, PortIoType::Input))
172            .map(|p| p.name.clone())
173            .collect();
174        let outputs: Vec<String> = ports
175            .iter()
176            .filter(|p| matches!(p.io_type, PortIoType::Output))
177            .map(|p| p.name.clone())
178            .collect();
179        let bidirectional: Vec<String> = ports
180            .iter()
181            .filter(|p| matches!(p.io_type, PortIoType::Bidirectional))
182            .map(|p| p.name.clone())
183            .collect();
184        let unspecified: Vec<String> = ports
185            .iter()
186            .filter(|p| matches!(p.io_type, PortIoType::Unspecified))
187            .map(|p| p.name.clone())
188            .collect();
189
190        Some(InterfaceSummary {
191            inputs,
192            outputs,
193            bidirectional,
194            unspecified,
195        })
196    } else {
197        None
198    };
199
200    // Collect key signals
201    let mut net_labels: HashMap<String, usize> = HashMap::new();
202    for record in &doc.primitives {
203        if let SchRecord::NetLabel(nl) = record {
204            *net_labels.entry(nl.label.text.clone()).or_insert(0) += 1;
205        }
206    }
207
208    let data_buses: Vec<String> = net_labels
209        .iter()
210        .filter(|(n, _)| {
211            n.contains('[') || n.contains("DATA") || n.contains("D0") || n.contains("DQ")
212        })
213        .map(|(n, _)| n.clone())
214        .collect();
215    let address_buses: Vec<String> = net_labels
216        .iter()
217        .filter(|(n, _)| n.contains("ADDR") || n.contains("A0") || n.starts_with("A["))
218        .map(|(n, _)| n.clone())
219        .collect();
220    let control_signals: Vec<String> = net_labels
221        .iter()
222        .filter(|(n, _)| {
223            n.contains("CLK")
224                || n.contains("RESET")
225                || n.contains("EN")
226                || n.contains("CS")
227                || n.contains("WR")
228                || n.contains("RD")
229                || n.contains("_B")
230        })
231        .filter(|(n, _)| !n.contains('['))
232        .map(|(n, _)| n.clone())
233        .collect();
234
235    let key_signals = KeySignals {
236        total_unique_nets: net_labels.len(),
237        data_buses,
238        address_buses,
239        control_signals,
240    };
241
242    // Quick stats
243    let quick_stats = SchDocQuickStats {
244        components: counts.get("Component").copied().unwrap_or(0),
245        wires: counts.get("Wire").copied().unwrap_or(0),
246        junctions: counts.get("Junction").copied().unwrap_or(0),
247        net_labels: counts.get("NetLabel").copied().unwrap_or(0),
248        ports: counts.get("Port").copied().unwrap_or(0),
249        power_symbols: counts.get("PowerObject").copied().unwrap_or(0),
250    };
251
252    Ok(SchDocOverview {
253        path: path.display().to_string(),
254        sheet_size,
255        components_by_category,
256        power_architecture,
257        interfaces,
258        key_signals,
259        quick_stats,
260    })
261}
262
263/// Bill of materials.
264pub fn cmd_bom(path: &Path) -> Result<SchDocBom, Box<dyn std::error::Error>> {
265    let doc = open_schdoc(path)?;
266    let tree = RecordTree::from_records(doc.primitives.clone());
267
268    // Group by library reference
269    let mut bom: HashMap<String, Vec<(String, String)>> = HashMap::new();
270    for (id, record) in tree.iter() {
271        if let SchRecord::Component(c) = record {
272            let des = get_component_designator(&tree, id).unwrap_or_else(|| "<none>".to_string());
273            bom.entry(c.lib_reference.clone())
274                .or_default()
275                .push((des, c.component_description.clone()));
276        }
277    }
278
279    // Sort by quantity (most used first)
280    let mut sorted: Vec<_> = bom.iter().collect();
281    sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
282
283    let total_components = sorted.iter().map(|(_, items)| items.len()).sum();
284    let unique_parts = bom.len();
285
286    let items: Vec<BomItem> = sorted
287        .iter()
288        .map(|(lib_ref, comps)| {
289            let mut designators: Vec<_> = comps.iter().map(|(d, _)| d.clone()).collect();
290            designators.sort_by(|a, b| alphanumeric_sort(a, b));
291
292            let description = comps
293                .first()
294                .map(|(_, desc)| desc.clone())
295                .unwrap_or_default();
296
297            BomItem {
298                lib_reference: lib_ref.to_string(),
299                quantity: comps.len(),
300                designators,
301                description,
302            }
303        })
304        .collect();
305
306    Ok(SchDocBom {
307        path: path.display().to_string(),
308        total_components,
309        unique_parts,
310        items,
311    })
312}
313
314/// Net connectivity map.
315#[allow(clippy::type_complexity)]
316pub fn cmd_netlist(
317    path: &Path,
318    net_filter: Option<String>,
319    min_connections: usize,
320) -> Result<SchDocNetlist, Box<dyn std::error::Error>> {
321    let doc = open_schdoc(path)?;
322    let tree = RecordTree::from_records(doc.primitives.clone());
323
324    // Build component designator lookup and pin locations
325    let mut pin_locations: HashMap<(i32, i32), Vec<(String, String, String)>> = HashMap::new();
326    for (id, record) in tree.iter() {
327        if let SchRecord::Component(_) = record {
328            let des =
329                get_component_designator(&tree, id).unwrap_or_else(|| format!("?{}", id.index()));
330            for (pin_des, pin_name, corner_x, corner_y) in get_component_pins(&tree, id) {
331                pin_locations
332                    .entry((corner_x, corner_y))
333                    .or_default()
334                    .push((des.clone(), pin_des, pin_name));
335            }
336        }
337    }
338
339    // Build net name to location mapping
340    let mut net_at_location: HashMap<(i32, i32), String> = HashMap::new();
341    for record in &doc.primitives {
342        match record {
343            SchRecord::NetLabel(nl) => {
344                net_at_location.insert(
345                    (nl.label.graphical.location_x, nl.label.graphical.location_y),
346                    nl.label.text.clone(),
347                );
348            }
349            SchRecord::PowerObject(p) => {
350                net_at_location.insert(
351                    (p.graphical.location_x, p.graphical.location_y),
352                    p.text.clone(),
353                );
354            }
355            _ => {}
356        }
357    }
358
359    // Group connections by net name
360    let mut nets: HashMap<String, Vec<String>> = HashMap::new();
361    let proximity_threshold = 100000; // 10 mils
362
363    for ((net_x, net_y), net_name) in &net_at_location {
364        for ((pin_x, pin_y), pins) in &pin_locations {
365            if (net_x - pin_x).abs() < proximity_threshold
366                && (net_y - pin_y).abs() < proximity_threshold
367            {
368                for (comp_des, pin_des, pin_name) in pins {
369                    nets.entry(net_name.clone())
370                        .or_default()
371                        .push(format!("{}.{} ({})", comp_des, pin_des, pin_name));
372                }
373            }
374        }
375    }
376
377    // Apply filters
378    let mut filtered_nets: Vec<_> = nets
379        .iter()
380        .filter(|(name, conns)| {
381            let pass_filter = match &net_filter {
382                Some(f) if f.contains('*') => name.contains(&f.replace('*', "")),
383                Some(f) => name.eq_ignore_ascii_case(f),
384                None => true,
385            };
386            pass_filter && conns.len() >= min_connections
387        })
388        .collect();
389    filtered_nets.sort_by(|a, b| a.0.cmp(b.0));
390
391    let net_connections: Vec<NetConnection> = filtered_nets
392        .iter()
393        .map(|(name, conns)| NetConnection {
394            net_name: (*name).clone(),
395            connections: conns.to_vec(),
396        })
397        .collect();
398
399    Ok(SchDocNetlist {
400        path: path.display().to_string(),
401        filter: net_filter,
402        min_connections,
403        total_nets: net_connections.len(),
404        nets: net_connections,
405    })
406}
407
408/// Power distribution map.
409pub fn cmd_power_map(path: &Path) -> Result<SchDocPowerMap, Box<dyn std::error::Error>> {
410    let doc = open_schdoc_boxed(path)?;
411    let tree = RecordTree::from_records(doc.primitives.clone());
412
413    // Build component info
414    let mut comp_info: HashMap<RecordId, (String, String)> = HashMap::new();
415    let mut power_pins: HashMap<RecordId, Vec<(String, String)>> = HashMap::new(); // comp_id -> [(pin_des, pin_name)]
416
417    for (id, record) in tree.iter() {
418        if let SchRecord::Component(c) = record {
419            let des =
420                get_component_designator(&tree, id).unwrap_or_else(|| format!("?{}", id.index()));
421            comp_info.insert(id, (des.clone(), c.lib_reference.clone()));
422
423            // Find power pins
424            for (_, child) in tree.children(id) {
425                if let SchRecord::Pin(p) = child {
426                    let name_upper = p.name.to_uppercase();
427                    if name_upper.contains("VCC")
428                        || name_upper.contains("VDD")
429                        || name_upper.contains("GND")
430                        || name_upper.contains("VSS")
431                        || name_upper.contains("AVCC")
432                        || name_upper.contains("AVDD")
433                        || name_upper.contains("AGND")
434                        || name_upper.contains("DVCC")
435                        || name_upper.contains("DVDD")
436                        || name_upper.contains("DGND")
437                        || name_upper.contains("VIN")
438                        || name_upper.contains("VOUT")
439                        || name_upper.contains("PWR")
440                        || name_upper.contains("POWER")
441                        || format!("{:?}", p.electrical).contains("Power")
442                    {
443                        power_pins
444                            .entry(id)
445                            .or_default()
446                            .push((p.designator.clone(), p.name.clone()));
447                    }
448                }
449            }
450        }
451    }
452
453    // Get power symbols and their nets
454    let mut power_nets: HashMap<String, Vec<(i32, i32)>> = HashMap::new();
455    for record in &doc.primitives {
456        if let SchRecord::PowerObject(p) = record {
457            power_nets
458                .entry(p.text.clone())
459                .or_default()
460                .push((p.graphical.location_x, p.graphical.location_y));
461        }
462    }
463
464    // Separate into power rails and grounds
465    let mut rails: Vec<_> = power_nets
466        .iter()
467        .filter(|(name, _)| {
468            !name.to_uppercase().contains("GND") && !name.to_uppercase().contains("VSS")
469        })
470        .collect();
471    let mut grounds: Vec<_> = power_nets
472        .iter()
473        .filter(|(name, _)| {
474            name.to_uppercase().contains("GND") || name.to_uppercase().contains("VSS")
475        })
476        .collect();
477
478    rails.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
479    grounds.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
480
481    // Build power rails with consumers
482    let power_rails: Vec<PowerRail> = rails
483        .iter()
484        .map(|(net_name, locations)| {
485            // Find components with power pins near these locations
486            let mut consumers = Vec::new();
487            for (comp_id, pins) in &power_pins {
488                if let Some((des, _lib_ref)) = comp_info.get(comp_id) {
489                    for (_pin_des, pin_name) in pins {
490                        if pin_name.to_uppercase().contains(&net_name.to_uppercase())
491                            || (net_name.contains("3V3") && pin_name.contains("3V3"))
492                            || (net_name.contains("5V") && pin_name.contains("5V"))
493                            || (net_name.contains("1V")
494                                && (pin_name.contains("1V") || pin_name.contains("VDD")))
495                        {
496                            consumers.push(format!("{} ({})", des, pin_name));
497                        }
498                    }
499                }
500            }
501            consumers.sort();
502            consumers.dedup();
503
504            PowerRail {
505                net_name: (*net_name).clone(),
506                symbol_count: locations.len(),
507                consumers,
508            }
509        })
510        .collect();
511
512    // Build ground nets
513    let ground_nets: Vec<GroundNet> = grounds
514        .iter()
515        .map(|(net_name, locations)| GroundNet {
516            net_name: (*net_name).clone(),
517            symbol_count: locations.len(),
518        })
519        .collect();
520
521    // Build powered components
522    let mut powered_components: Vec<_> = power_pins
523        .iter()
524        .filter_map(|(id, pins)| {
525            comp_info.get(id).map(|(des, lib_ref)| PoweredComponent {
526                designator: des.clone(),
527                lib_reference: lib_ref.clone(),
528                power_pin_count: pins.len(),
529            })
530        })
531        .collect();
532    powered_components.sort_by(|a, b| b.power_pin_count.cmp(&a.power_pin_count));
533
534    Ok(SchDocPowerMap {
535        path: path.display().to_string(),
536        power_rails,
537        ground_nets,
538        powered_components,
539    })
540}
541
542/// Block diagram - shows major ICs as functional blocks.
543pub fn cmd_blocks(path: &Path, show_all: bool) -> Result<SchDocBlocks, Box<dyn std::error::Error>> {
544    let doc = open_schdoc_boxed(path)?;
545    let tree = RecordTree::from_records(doc.primitives.clone());
546
547    let mut blocks: Vec<BlockInfo> = Vec::new();
548
549    for (id, record) in tree.iter() {
550        if let SchRecord::Component(c) = record {
551            let des = get_component_designator(&tree, id).unwrap_or_else(|| "<none>".to_string());
552            let category = categorize_component(&c.lib_reference, &c.component_description);
553
554            // Skip passives unless show_all is set
555            if !show_all {
556                let skip_categories = ["Capacitor", "Resistor", "Inductor/Ferrite", "Test Point"];
557                if skip_categories.contains(&category) {
558                    continue;
559                }
560            }
561
562            let mut power_pins = Vec::new();
563            let mut input_pins = Vec::new();
564            let mut output_pins = Vec::new();
565            let mut bidir_pins = Vec::new();
566
567            // Categorize pins
568            for (_, child) in tree.children(id) {
569                if let SchRecord::Pin(p) = child {
570                    if p.is_hidden() {
571                        continue;
572                    }
573                    let pin_info = if p.name.is_empty() {
574                        p.designator.clone()
575                    } else if p.name.len() > 15 {
576                        format!("{}...", &p.name[..12])
577                    } else {
578                        p.name.clone()
579                    };
580
581                    match p.electrical {
582                        PinElectricalType::Power => power_pins.push(pin_info),
583                        PinElectricalType::Input => input_pins.push(pin_info),
584                        PinElectricalType::Output => output_pins.push(pin_info),
585                        PinElectricalType::InputOutput => bidir_pins.push(pin_info),
586                        _ => bidir_pins.push(pin_info), // Passive, etc.
587                    }
588                }
589            }
590
591            blocks.push(BlockInfo {
592                designator: des,
593                lib_reference: c.lib_reference.clone(),
594                description: c.component_description.clone(),
595                category: category.to_string(),
596                power_pins,
597                input_pins,
598                output_pins,
599                bidir_pins,
600            });
601        }
602    }
603
604    // Sort by category importance
605    let category_priority: HashMap<&str, usize> = [
606        ("Microcontroller", 0),
607        ("FPGA/CPLD", 1),
608        ("Memory", 2),
609        ("ADC", 3),
610        ("DAC", 4),
611        ("Transceiver/PHY", 5),
612        ("Clock/Oscillator", 6),
613        ("Power Supply", 7),
614        ("Amplifier", 8),
615        ("Mux/Switch", 9),
616        ("Buffer/Driver", 10),
617        ("Other IC", 11),
618    ]
619    .iter()
620    .cloned()
621    .collect();
622
623    blocks.sort_by(|a, b| {
624        let pa = category_priority.get(a.category.as_str()).unwrap_or(&99);
625        let pb = category_priority.get(b.category.as_str()).unwrap_or(&99);
626        pa.cmp(pb)
627            .then_with(|| alphanumeric_sort(&a.designator, &b.designator))
628    });
629
630    Ok(SchDocBlocks {
631        path: path.display().to_string(),
632        blocks,
633        show_all,
634    })
635}
636
637/// Multi-file project analysis.
638pub fn cmd_project(paths: &[PathBuf]) -> Result<SchDocProjectAnalysis, Box<dyn std::error::Error>> {
639    if paths.is_empty() {
640        return Err("No schematic files specified".into());
641    }
642
643    // Collect info from all files
644    struct LocalSheetInfo {
645        name: String,
646        components: usize,
647        ports: Vec<(String, String)>, // (name, io_type)
648        power_nets: Vec<String>,
649        unique_nets: Vec<String>,
650    }
651
652    let mut sheets: Vec<LocalSheetInfo> = Vec::new();
653    let mut all_ports: HashMap<String, Vec<(String, String)>> = HashMap::new(); // port_name -> [(sheet, io_type)]
654
655    for path in paths {
656        let doc = match open_schdoc(path) {
657            Ok(d) => d,
658            Err(_e) => {
659                // Skip files that can't be opened
660                continue;
661            }
662        };
663
664        let sheet_name = path
665            .file_stem()
666            .and_then(|s| s.to_str())
667            .unwrap_or("unknown")
668            .to_string();
669
670        let component_count = doc
671            .primitives
672            .iter()
673            .filter(|r| matches!(r, SchRecord::Component(_)))
674            .count();
675
676        let mut ports: Vec<(String, String)> = Vec::new();
677        for record in &doc.primitives {
678            if let SchRecord::Port(p) = record {
679                let io = match p.io_type {
680                    PortIoType::Input => "IN",
681                    PortIoType::Output => "OUT",
682                    PortIoType::Bidirectional => "BIDIR",
683                    PortIoType::Unspecified => "BUS",
684                };
685                ports.push((p.name.clone(), io.to_string()));
686                all_ports
687                    .entry(p.name.clone())
688                    .or_default()
689                    .push((sheet_name.clone(), io.to_string()));
690            }
691        }
692
693        let mut power_nets: Vec<String> = doc
694            .primitives
695            .iter()
696            .filter_map(|r| {
697                if let SchRecord::PowerObject(p) = r {
698                    Some(p.text.clone())
699                } else {
700                    None
701                }
702            })
703            .collect();
704        power_nets.sort();
705        power_nets.dedup();
706
707        let mut unique_nets: Vec<String> = doc
708            .primitives
709            .iter()
710            .filter_map(|r| {
711                if let SchRecord::NetLabel(nl) = r {
712                    Some(nl.label.text.clone())
713                } else {
714                    None
715                }
716            })
717            .collect();
718        unique_nets.sort();
719        unique_nets.dedup();
720
721        sheets.push(LocalSheetInfo {
722            name: sheet_name,
723            components: component_count,
724            ports,
725            power_nets,
726            unique_nets,
727        });
728    }
729
730    // Build output structures
731    let output_sheets: Vec<SheetInfo> = sheets
732        .iter()
733        .map(|s| SheetInfo {
734            name: s.name.clone(),
735            component_count: s.components,
736            port_count: s.ports.len(),
737            net_count: s.unique_nets.len(),
738            ports: s.ports.clone(),
739            power_nets: s.power_nets.clone(),
740        })
741        .collect();
742
743    // Find inter-sheet connections (ports that appear on multiple sheets)
744    let mut connections: Vec<_> = all_ports
745        .iter()
746        .filter(|(_, sheets)| sheets.len() > 1)
747        .collect();
748
749    connections.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
750
751    let inter_sheet_connections: Vec<InterSheetConnection> = connections
752        .into_iter()
753        .map(|(port_name, connected_sheets)| InterSheetConnection {
754            port_name: port_name.to_string(),
755            connected_sheets: connected_sheets.clone(),
756        })
757        .collect();
758
759    Ok(SchDocProjectAnalysis {
760        sheet_count: output_sheets.len(),
761        sheets: output_sheets,
762        inter_sheet_connections,
763    })
764}
765
766/// Signal flow analysis.
767pub fn cmd_signal_flow(
768    path: &Path,
769    signal: &str,
770) -> Result<SchDocSignalFlow, Box<dyn std::error::Error>> {
771    let doc = open_schdoc(path)?;
772    let tree = RecordTree::from_records(doc.primitives.clone());
773
774    // Find all net labels matching the signal
775    let matching_nets: Vec<_> = doc
776        .primitives
777        .iter()
778        .filter_map(|r| {
779            if let SchRecord::NetLabel(nl) = r {
780                if nl.label.text.eq_ignore_ascii_case(signal)
781                    || nl
782                        .label
783                        .text
784                        .to_uppercase()
785                        .contains(&signal.to_uppercase())
786                {
787                    Some((
788                        nl.label.text.clone(),
789                        nl.label.graphical.location_x,
790                        nl.label.graphical.location_y,
791                    ))
792                } else {
793                    None
794                }
795            } else {
796                None
797            }
798        })
799        .collect();
800
801    // Also check power objects
802    let matching_power: Vec<_> = doc
803        .primitives
804        .iter()
805        .filter_map(|r| {
806            if let SchRecord::PowerObject(p) = r {
807                if p.text.eq_ignore_ascii_case(signal)
808                    || p.text.to_uppercase().contains(&signal.to_uppercase())
809                {
810                    Some((
811                        p.text.clone(),
812                        p.graphical.location_x,
813                        p.graphical.location_y,
814                    ))
815                } else {
816                    None
817                }
818            } else {
819                None
820            }
821        })
822        .collect();
823
824    // Also check ports
825    let matching_ports: Vec<_> = doc
826        .primitives
827        .iter()
828        .filter_map(|r| {
829            if let SchRecord::Port(p) = r {
830                if p.name.eq_ignore_ascii_case(signal)
831                    || p.name.to_uppercase().contains(&signal.to_uppercase())
832                {
833                    Some((p.name.clone(), format!("{:?}", p.io_type)))
834                } else {
835                    None
836                }
837            } else {
838                None
839            }
840        })
841        .collect();
842
843    if matching_nets.is_empty() && matching_power.is_empty() && matching_ports.is_empty() {
844        return Ok(SchDocSignalFlow {
845            path: path.display().to_string(),
846            signal: signal.to_string(),
847            trace_found: false,
848            trace: None,
849        });
850    }
851
852    // Build trace path
853    let mut trace_path = Vec::new();
854
855    // Add net labels
856    for (name, x, y) in &matching_nets {
857        trace_path.push(format!("NetLabel {} at {}", name, fmt_point(*x, *y)));
858    }
859
860    // Add power symbols
861    for (name, x, y) in &matching_power {
862        trace_path.push(format!("Power {} at {}", name, fmt_point(*x, *y)));
863    }
864
865    // Add ports
866    for (name, io_type) in &matching_ports {
867        trace_path.push(format!("Port {} [{}]", name, io_type));
868    }
869
870    // Find components with matching pins
871    let signal_upper = signal.to_uppercase();
872    let mut destinations = Vec::new();
873
874    for (id, record) in tree.iter() {
875        if let SchRecord::Component(_c) = record {
876            let des = get_component_designator(&tree, id).unwrap_or_default();
877
878            for (_, child) in tree.children(id) {
879                if let SchRecord::Pin(p) = child {
880                    if p.name.to_uppercase().contains(&signal_upper)
881                        || p.designator.to_uppercase().contains(&signal_upper)
882                    {
883                        destinations.push(format!(
884                            "{}.{} ({}) - {:?}",
885                            des, p.designator, p.name, p.electrical
886                        ));
887                    }
888                }
889            }
890        }
891    }
892
893    let source = if !matching_ports.is_empty() {
894        format!("Port {}", matching_ports[0].0)
895    } else if !matching_power.is_empty() {
896        format!("Power {}", matching_power[0].0)
897    } else {
898        format!("Net {}", matching_nets[0].0)
899    };
900
901    Ok(SchDocSignalFlow {
902        path: path.display().to_string(),
903        signal: signal.to_string(),
904        trace_found: true,
905        trace: Some(SignalTrace {
906            source,
907            path: trace_path,
908            destinations,
909        }),
910    })
911}
912
913// ═══════════════════════════════════════════════════════════════════════════
914// DETAILED COMMANDS
915// ═══════════════════════════════════════════════════════════════════════════
916
917/// Show document overview.
918pub fn cmd_info(path: &Path) -> Result<SchDocInfo, Box<dyn std::error::Error>> {
919    let doc = open_schdoc(path)?;
920    let counts = count_record_types(&doc);
921
922    // Sheet info
923    let sheet_info = doc.sheet_header().map(|header| {
924        let custom_dimensions = if header.custom_x > 0 || header.custom_y > 0 {
925            Some((
926                fmt_coord(header.custom_x * 10000),
927                fmt_coord(header.custom_y * 10000),
928            ))
929        } else {
930            None
931        };
932        SheetInfoDetails {
933            size: sheet_size_name(header.sheet_size).to_string(),
934            size_style: header.sheet_size,
935            custom_dimensions,
936            fonts_defined: header.font_id_count,
937        }
938    });
939
940    // Primitive summary
941    let primitive_summary = PrimitiveSummary {
942        total_primitives: doc.primitives.len(),
943        components: counts.get("Component").copied().unwrap_or(0),
944        wires: counts.get("Wire").copied().unwrap_or(0),
945        net_labels: counts.get("NetLabel").copied().unwrap_or(0),
946        ports: counts.get("Port").copied().unwrap_or(0),
947        power_objects: counts.get("PowerObject").copied().unwrap_or(0),
948        junctions: counts.get("Junction").copied().unwrap_or(0),
949        pins: counts.get("Pin").copied().unwrap_or(0),
950    };
951
952    // Collect unique net names
953    let mut net_names: Vec<String> = doc
954        .primitives
955        .iter()
956        .filter_map(|r| {
957            if let SchRecord::NetLabel(nl) = r {
958                Some(nl.label.text.clone())
959            } else {
960                None
961            }
962        })
963        .collect();
964    net_names.sort();
965    net_names.dedup();
966
967    // Collect unique power nets
968    let mut power_nets: Vec<String> = doc
969        .primitives
970        .iter()
971        .filter_map(|r| {
972            if let SchRecord::PowerObject(p) = r {
973                Some(p.text.clone())
974            } else {
975                None
976            }
977        })
978        .collect();
979    power_nets.sort();
980    power_nets.dedup();
981
982    Ok(SchDocInfo {
983        path: path.display().to_string(),
984        sheet_info,
985        primitive_summary,
986        unique_nets: net_names,
987        power_nets,
988    })
989}
990
991/// Show detailed record statistics.
992pub fn cmd_stats(path: &Path) -> Result<SchDocStats, Box<dyn std::error::Error>> {
993    let doc = open_schdoc(path)?;
994    let counts = count_record_types(&doc);
995
996    let mut record_types: Vec<(String, usize)> = counts
997        .into_iter()
998        .map(|(name, count)| (name.to_string(), count))
999        .collect();
1000    record_types.sort_by(|a, b| b.1.cmp(&a.1));
1001
1002    Ok(SchDocStats {
1003        path: path.display().to_string(),
1004        total_primitives: doc.primitives.len(),
1005        record_types,
1006    })
1007}
1008
1009/// List all components.
1010pub fn cmd_components(
1011    path: &Path,
1012    verbose: bool,
1013) -> Result<SchDocComponentList, Box<dyn std::error::Error>> {
1014    let doc = open_schdoc(path)?;
1015    let tree = RecordTree::from_records(doc.primitives.clone());
1016
1017    let mut component_data: Vec<(RecordId, &SchComponent, Option<String>)> = Vec::new();
1018    for (id, record) in tree.iter() {
1019        if let SchRecord::Component(c) = record {
1020            let designator = get_component_designator(&tree, id);
1021            component_data.push((id, c, designator));
1022        }
1023    }
1024
1025    // Sort by designator
1026    component_data.sort_by(|a, b| {
1027        let a_des = a.2.as_deref().unwrap_or("");
1028        let b_des = b.2.as_deref().unwrap_or("");
1029        alphanumeric_sort(a_des, b_des)
1030    });
1031
1032    let components = component_data
1033        .iter()
1034        .map(|(id, comp, designator)| {
1035            let child_count = if verbose {
1036                Some(tree.child_count(*id))
1037            } else {
1038                None
1039            };
1040            SchDocComponentInfo {
1041                designator: designator.clone().unwrap_or_else(|| "<none>".to_string()),
1042                lib_reference: comp.lib_reference.clone(),
1043                description: comp.component_description.clone(),
1044                location: fmt_point(comp.graphical.location_x, comp.graphical.location_y),
1045                parts: comp.part_count,
1046                child_count,
1047            }
1048        })
1049        .collect();
1050
1051    Ok(SchDocComponentList {
1052        path: path.display().to_string(),
1053        total_components: component_data.len(),
1054        components,
1055    })
1056}
1057
1058/// Show component details.
1059pub fn cmd_component(
1060    path: &Path,
1061    designator: &str,
1062    show_children: bool,
1063) -> Result<SchDocComponentDetail, Box<dyn std::error::Error>> {
1064    let doc = open_schdoc(path)?;
1065    let tree = RecordTree::from_records(doc.primitives.clone());
1066
1067    // Find component by designator or index
1068    let component_id = if let Ok(index) = designator.parse::<usize>() {
1069        // Numeric index
1070        let mut comp_idx = 0;
1071        let mut found_id = None;
1072        for (id, record) in tree.iter() {
1073            if matches!(record, SchRecord::Component(_)) {
1074                if comp_idx == index {
1075                    found_id = Some(id);
1076                    break;
1077                }
1078                comp_idx += 1;
1079            }
1080        }
1081        found_id.ok_or_else(|| format!("Component index {} not found", index))?
1082    } else {
1083        // Find by designator
1084        let mut found_id = None;
1085        for (id, record) in tree.iter() {
1086            if matches!(record, SchRecord::Component(_)) {
1087                if let Some(des) = get_component_designator(&tree, id) {
1088                    if des.eq_ignore_ascii_case(designator) {
1089                        found_id = Some(id);
1090                        break;
1091                    }
1092                }
1093            }
1094        }
1095        found_id.ok_or_else(|| format!("Component '{}' not found", designator))?
1096    };
1097
1098    let comp = match tree.get(component_id) {
1099        Some(SchRecord::Component(c)) => c,
1100        _ => return Err("Invalid component".into()),
1101    };
1102
1103    let actual_designator = get_component_designator(&tree, component_id);
1104
1105    // Count and categorize children
1106    let children: Vec<_> = tree.children(component_id).collect();
1107    let mut pin_infos = Vec::new();
1108    let mut param_infos = Vec::new();
1109    let mut designator_infos = Vec::new();
1110    let mut graphics_count = 0;
1111
1112    for (_id, child) in &children {
1113        match child {
1114            SchRecord::Pin(p) => {
1115                pin_infos.push(SchDocPinInfo {
1116                    designator: p.designator.clone(),
1117                    name: p.name.clone(),
1118                    electrical_type: format!("{:?}", p.electrical),
1119                    hidden: p.is_hidden(),
1120                });
1121            }
1122            SchRecord::Parameter(p) => {
1123                param_infos.push(SchDocParameter {
1124                    name: p.name.clone(),
1125                    value: p.label.text.clone(),
1126                });
1127            }
1128            SchRecord::Designator(d) => {
1129                designator_infos.push(SchDocDesignator {
1130                    name: d.param.name.clone(),
1131                    value: d.param.label.text.clone(),
1132                });
1133            }
1134            _ => graphics_count += 1,
1135        }
1136    }
1137
1138    Ok(SchDocComponentDetail {
1139        designator: actual_designator.unwrap_or_else(|| "<none>".to_string()),
1140        lib_reference: comp.lib_reference.clone(),
1141        description: comp.component_description.clone(),
1142        location: fmt_point(comp.graphical.location_x, comp.graphical.location_y),
1143        parts: comp.part_count,
1144        display_modes: comp.display_mode_count,
1145        current_part: comp.current_part_id,
1146        unique_id: comp.unique_id.clone(),
1147        child_primitive_count: children.len(),
1148        pins: pin_infos,
1149        parameters: param_infos,
1150        designators: designator_infos,
1151        graphic_primitive_count: if show_children {
1152            Some(graphics_count)
1153        } else {
1154            None
1155        },
1156    })
1157}
1158
1159/// List all wires.
1160pub fn cmd_wires(
1161    path: &Path,
1162    limit: Option<usize>,
1163) -> Result<SchDocWireList, Box<dyn std::error::Error>> {
1164    let doc = open_schdoc(path)?;
1165
1166    let wires: Vec<&SchWire> = doc
1167        .primitives
1168        .iter()
1169        .filter_map(|r| {
1170            if let SchRecord::Wire(w) = r {
1171                Some(w)
1172            } else {
1173                None
1174            }
1175        })
1176        .collect();
1177
1178    let display_count = limit.unwrap_or(wires.len()).min(wires.len());
1179
1180    let wire_infos: Vec<WireInfo> = wires
1181        .iter()
1182        .take(display_count)
1183        .enumerate()
1184        .map(|(i, wire)| {
1185            let vertices = &wire.vertices;
1186            let (start, end_or_segments) = if vertices.len() == 2 {
1187                (
1188                    fmt_point(vertices[0].0, vertices[0].1),
1189                    fmt_point(vertices[1].0, vertices[1].1),
1190                )
1191            } else {
1192                let start = if vertices.is_empty() {
1193                    "(empty)".to_string()
1194                } else {
1195                    fmt_point(vertices[0].0, vertices[0].1)
1196                };
1197                let segments = format!("{} segments", vertices.len().saturating_sub(1));
1198                (start, segments)
1199            };
1200            WireInfo {
1201                index: i,
1202                start,
1203                end_or_segments,
1204            }
1205        })
1206        .collect();
1207
1208    Ok(SchDocWireList {
1209        path: path.display().to_string(),
1210        total_wires: wires.len(),
1211        wires: wire_infos,
1212    })
1213}
1214
1215/// List all net labels.
1216pub fn cmd_nets(
1217    path: &Path,
1218    group: bool,
1219) -> Result<SchDocNetLabelList, Box<dyn std::error::Error>> {
1220    let doc = open_schdoc(path)?;
1221
1222    let net_labels: Vec<&SchNetLabel> = doc
1223        .primitives
1224        .iter()
1225        .filter_map(|r| {
1226            if let SchRecord::NetLabel(nl) = r {
1227                Some(nl)
1228            } else {
1229                None
1230            }
1231        })
1232        .collect();
1233
1234    let (grouped, individual) = if group {
1235        let mut grouped_map: HashMap<&str, Vec<&SchNetLabel>> = HashMap::new();
1236        for nl in &net_labels {
1237            grouped_map.entry(&nl.label.text).or_default().push(nl);
1238        }
1239
1240        let mut sorted: Vec<_> = grouped_map.iter().collect();
1241        sorted.sort_by(|a, b| a.0.cmp(b.0));
1242
1243        let grouped_result: Vec<(String, usize)> = sorted
1244            .into_iter()
1245            .map(|(name, labels)| (name.to_string(), labels.len()))
1246            .collect();
1247
1248        (Some(grouped_result), None)
1249    } else {
1250        let individual_result: Vec<NetLabelInfo> = net_labels
1251            .iter()
1252            .map(|nl| NetLabelInfo {
1253                net_name: nl.label.text.clone(),
1254                location: fmt_point(nl.label.graphical.location_x, nl.label.graphical.location_y),
1255            })
1256            .collect();
1257
1258        (None, Some(individual_result))
1259    };
1260
1261    Ok(SchDocNetLabelList {
1262        path: path.display().to_string(),
1263        total_net_labels: net_labels.len(),
1264        group_by_name: group,
1265        grouped,
1266        individual,
1267    })
1268}
1269
1270/// List all ports.
1271pub fn cmd_ports(path: &Path) -> Result<SchDocPortList, Box<dyn std::error::Error>> {
1272    let doc = open_schdoc(path)?;
1273
1274    let ports: Vec<&SchPort> = doc
1275        .primitives
1276        .iter()
1277        .filter_map(|r| {
1278            if let SchRecord::Port(p) = r {
1279                Some(p)
1280            } else {
1281                None
1282            }
1283        })
1284        .collect();
1285
1286    let port_infos: Vec<PortInfo> = ports
1287        .iter()
1288        .map(|port| {
1289            let io_type = match port.io_type {
1290                PortIoType::Unspecified => "Unspec",
1291                PortIoType::Output => "Output",
1292                PortIoType::Input => "Input",
1293                PortIoType::Bidirectional => "Bidir",
1294            };
1295            PortInfo {
1296                name: port.name.clone(),
1297                io_type: io_type.to_string(),
1298                location: fmt_point(port.graphical.location_x, port.graphical.location_y),
1299            }
1300        })
1301        .collect();
1302
1303    Ok(SchDocPortList {
1304        path: path.display().to_string(),
1305        total_ports: ports.len(),
1306        ports: port_infos,
1307    })
1308}
1309
1310/// List all power objects.
1311pub fn cmd_power(path: &Path, group: bool) -> Result<SchDocPowerList, Box<dyn std::error::Error>> {
1312    let doc = open_schdoc(path)?;
1313
1314    let power_objects: Vec<&SchPowerObject> = doc
1315        .primitives
1316        .iter()
1317        .filter_map(|r| {
1318            if let SchRecord::PowerObject(p) = r {
1319                Some(p)
1320            } else {
1321                None
1322            }
1323        })
1324        .collect();
1325
1326    let (grouped, individual) = if group {
1327        let mut grouped_map: HashMap<&str, Vec<&SchPowerObject>> = HashMap::new();
1328        for p in &power_objects {
1329            grouped_map.entry(&p.text).or_default().push(p);
1330        }
1331
1332        let mut sorted: Vec<_> = grouped_map.iter().collect();
1333        sorted.sort_by(|a, b| a.0.cmp(b.0));
1334
1335        let grouped_result: Vec<(String, usize)> = sorted
1336            .into_iter()
1337            .map(|(name, objs)| (name.to_string(), objs.len()))
1338            .collect();
1339
1340        (Some(grouped_result), None)
1341    } else {
1342        let individual_result: Vec<PowerObjectInfo> = power_objects
1343            .iter()
1344            .map(|p| {
1345                let style = match p.style {
1346                    PowerObjectStyle::Arrow => "Arrow",
1347                    PowerObjectStyle::Bar => "Bar",
1348                    PowerObjectStyle::Wave => "Wave",
1349                    PowerObjectStyle::Ground => "Ground",
1350                    PowerObjectStyle::PowerGround => "PowerGnd",
1351                    PowerObjectStyle::SignalGround => "SignalGnd",
1352                    PowerObjectStyle::EarthGround => "EarthGnd",
1353                    PowerObjectStyle::Circle => "Circle",
1354                };
1355                PowerObjectInfo {
1356                    net: p.text.clone(),
1357                    style: style.to_string(),
1358                    location: fmt_point(p.graphical.location_x, p.graphical.location_y),
1359                }
1360            })
1361            .collect();
1362
1363        (None, Some(individual_result))
1364    };
1365
1366    Ok(SchDocPowerList {
1367        path: path.display().to_string(),
1368        total_power_objects: power_objects.len(),
1369        group_by_net: group,
1370        grouped,
1371        individual,
1372    })
1373}
1374
1375/// List pins.
1376pub fn cmd_pins(
1377    path: &Path,
1378    component_filter: Option<String>,
1379    _unconnected: bool,
1380) -> Result<SchDocPinList, Box<dyn std::error::Error>> {
1381    let doc = open_schdoc(path)?;
1382    let tree = RecordTree::from_records(doc.primitives.clone());
1383
1384    let mut pin_details = Vec::new();
1385    let total_pins: usize;
1386
1387    // If filtering by component, find the component first
1388    if let Some(ref comp_des) = component_filter {
1389        // Find by iterating
1390        let mut found_component = None;
1391        for (id, record) in tree.iter() {
1392            if matches!(record, SchRecord::Component(_)) {
1393                if let Some(des) = get_component_designator(&tree, id) {
1394                    if des.eq_ignore_ascii_case(comp_des) {
1395                        found_component = Some(id);
1396                        break;
1397                    }
1398                }
1399            }
1400        }
1401
1402        if let Some(comp_id) = found_component {
1403            let des = get_component_designator(&tree, comp_id).unwrap_or_default();
1404
1405            let pins: Vec<_> = tree
1406                .children(comp_id)
1407                .filter_map(|(_, r)| {
1408                    if let SchRecord::Pin(p) = r {
1409                        Some(p)
1410                    } else {
1411                        None
1412                    }
1413                })
1414                .collect();
1415
1416            total_pins = pins.len();
1417
1418            for pin in pins {
1419                pin_details.push(SchDocPinDetail {
1420                    component: des.clone(),
1421                    designator: pin.designator.clone(),
1422                    name: pin.name.clone(),
1423                    electrical_type: format!("{:?}", pin.electrical),
1424                    location: fmt_point(0, 0), // Pins don't have absolute location
1425                });
1426            }
1427        } else {
1428            return Err(format!("Component '{}' not found", comp_des).into());
1429        }
1430    } else {
1431        // All pins - need to build component map
1432        let mut comp_map: HashMap<RecordId, String> = HashMap::new();
1433        for (id, record) in tree.iter() {
1434            if let SchRecord::Component(_) = record {
1435                if let Some(des) = get_component_designator(&tree, id) {
1436                    comp_map.insert(id, des);
1437                }
1438            }
1439        }
1440
1441        // Collect all pins with their parent component
1442        for (id, record) in tree.iter() {
1443            if let SchRecord::Pin(p) = record {
1444                // Find parent component
1445                if let Some(parent_id) = tree.parent_id(id) {
1446                    let comp_des = comp_map.get(&parent_id).cloned().unwrap_or_default();
1447                    pin_details.push(SchDocPinDetail {
1448                        component: comp_des,
1449                        designator: p.designator.clone(),
1450                        name: p.name.clone(),
1451                        electrical_type: format!("{:?}", p.electrical),
1452                        location: fmt_point(0, 0),
1453                    });
1454                }
1455            }
1456        }
1457
1458        total_pins = pin_details.len();
1459    }
1460
1461    Ok(SchDocPinList {
1462        path: path.display().to_string(),
1463        total_pins,
1464        filter: component_filter,
1465        pins: pin_details,
1466    })
1467}
1468
1469/// Show hierarchy.
1470pub fn cmd_hierarchy(
1471    path: &Path,
1472    max_depth: Option<usize>,
1473    from_designator: Option<String>,
1474) -> Result<SchDocHierarchy, Box<dyn std::error::Error>> {
1475    let doc = open_schdoc(path)?;
1476    let tree = RecordTree::from_records(doc.primitives.clone());
1477
1478    let start_ids: Vec<RecordId> = if let Some(ref des) = from_designator {
1479        // Start from specific component
1480        let mut found = Vec::new();
1481        for (id, record) in tree.iter() {
1482            if matches!(record, SchRecord::Component(_)) {
1483                if let Some(comp_des) = get_component_designator(&tree, id) {
1484                    if comp_des.eq_ignore_ascii_case(des) {
1485                        found.push(id);
1486                        break;
1487                    }
1488                }
1489            }
1490        }
1491        if found.is_empty() {
1492            return Err(format!("Component '{}' not found", des).into());
1493        }
1494        found
1495    } else {
1496        // Start from roots
1497        tree.roots().map(|(id, _)| id).collect()
1498    };
1499
1500    let max_d = max_depth.unwrap_or(10);
1501    let hierarchy_nodes: Vec<HierarchyNode> = start_ids
1502        .into_iter()
1503        .map(|id| build_hierarchy_node(&tree, id, 0, max_d))
1504        .collect();
1505
1506    Ok(SchDocHierarchy {
1507        path: path.display().to_string(),
1508        hierarchy: hierarchy_nodes,
1509    })
1510}
1511
1512/// Helper function to build hierarchy node recursively.
1513fn build_hierarchy_node(
1514    tree: &RecordTree<SchRecord>,
1515    id: RecordId,
1516    depth: usize,
1517    max_depth: usize,
1518) -> HierarchyNode {
1519    if depth <= max_depth {
1520        let record = match tree.get(id) {
1521            Some(r) => r,
1522            None => {
1523                return HierarchyNode {
1524                    node_type: "error".to_string(),
1525                    unique_id: "Invalid ID".to_string(),
1526                    description: String::new(),
1527                    children: Vec::new(),
1528                };
1529            }
1530        };
1531
1532        // Format the node
1533        let (node_type, identifier, description) = match record {
1534            SchRecord::Component(c) => {
1535                let des = get_component_designator(tree, id).unwrap_or_default();
1536                ("component".to_string(), des, c.lib_reference.clone())
1537            }
1538            SchRecord::Pin(p) => ("pin".to_string(), p.designator.clone(), p.name.clone()),
1539            SchRecord::Parameter(p) => (
1540                "parameter".to_string(),
1541                p.name.clone(),
1542                p.label.text.clone(),
1543            ),
1544            SchRecord::Designator(d) => (
1545                "designator".to_string(),
1546                d.param.name.clone(),
1547                d.param.label.text.clone(),
1548            ),
1549            SchRecord::NetLabel(nl) => {
1550                ("netlabel".to_string(), nl.label.text.clone(), String::new())
1551            }
1552            SchRecord::Port(p) => (
1553                "port".to_string(),
1554                p.name.clone(),
1555                format!("{:?}", p.io_type),
1556            ),
1557            SchRecord::PowerObject(p) => (
1558                "power".to_string(),
1559                p.text.clone(),
1560                format!("{:?}", p.style),
1561            ),
1562            _ => (
1563                record_type_name(record).to_string(),
1564                format!("[{}]", id.index()),
1565                String::new(),
1566            ),
1567        };
1568
1569        // Build child hierarchy recursively
1570        let children: Vec<_> = tree
1571            .children(id)
1572            .map(|(child_id, _)| build_hierarchy_node(tree, child_id, depth + 1, max_depth))
1573            .collect();
1574
1575        HierarchyNode {
1576            node_type,
1577            unique_id: identifier,
1578            description,
1579            children,
1580        }
1581    } else {
1582        // Depth limit reached
1583        HierarchyNode {
1584            node_type: "...".to_string(),
1585            unique_id: format!("(depth limit {} reached)", max_depth),
1586            description: String::new(),
1587            children: Vec::new(),
1588        }
1589    }
1590}
1591
1592/// Search for text.
1593pub fn cmd_search(path: &Path, query: &str, limit: Option<usize>) -> Result<(), String> {
1594    let doc = open_schdoc(path)?;
1595    let tree = RecordTree::from_records(doc.primitives.clone());
1596
1597    let query_lower = query.to_lowercase();
1598    let max_results = limit.unwrap_or(50);
1599
1600    println!("Search Results: {}", path.display());
1601    println!("Query: \"{}\"", query);
1602    println!("═══════════════════════════════════════════════════════════════");
1603
1604    let mut results = Vec::new();
1605
1606    for (id, record) in tree.iter() {
1607        let matches = match record {
1608            SchRecord::Component(c) => {
1609                c.lib_reference.to_lowercase().contains(&query_lower)
1610                    || c.component_description
1611                        .to_lowercase()
1612                        .contains(&query_lower)
1613            }
1614            SchRecord::Pin(p) => {
1615                p.name.to_lowercase().contains(&query_lower)
1616                    || p.designator.to_lowercase().contains(&query_lower)
1617            }
1618            SchRecord::NetLabel(nl) => nl.label.text.to_lowercase().contains(&query_lower),
1619            SchRecord::Port(p) => p.name.to_lowercase().contains(&query_lower),
1620            SchRecord::PowerObject(p) => p.text.to_lowercase().contains(&query_lower),
1621            SchRecord::Label(l) => l.text.to_lowercase().contains(&query_lower),
1622            SchRecord::TextFrame(tf) => tf.text.to_lowercase().contains(&query_lower),
1623            SchRecord::Parameter(p) => {
1624                p.name.to_lowercase().contains(&query_lower)
1625                    || p.label.text.to_lowercase().contains(&query_lower)
1626            }
1627            SchRecord::Designator(d) => {
1628                d.param.name.to_lowercase().contains(&query_lower)
1629                    || d.param.label.text.to_lowercase().contains(&query_lower)
1630            }
1631            _ => false,
1632        };
1633
1634        if matches {
1635            results.push((id, record));
1636            if results.len() >= max_results {
1637                break;
1638            }
1639        }
1640    }
1641
1642    println!("\nFound {} results:\n", results.len());
1643
1644    for (id, record) in &results {
1645        let desc = match record {
1646            SchRecord::Component(c) => {
1647                let des = get_component_designator(&tree, *id).unwrap_or_default();
1648                format!("Component {} - {}", des, c.lib_reference)
1649            }
1650            SchRecord::Pin(p) => format!("Pin {} - {}", p.designator, p.name),
1651            SchRecord::NetLabel(nl) => format!("NetLabel: {}", nl.label.text),
1652            SchRecord::Port(p) => format!("Port: {}", p.name),
1653            SchRecord::PowerObject(p) => format!("Power: {}", p.text),
1654            SchRecord::Label(l) => format!("Label: {}", l.text),
1655            SchRecord::TextFrame(tf) => {
1656                let text = if tf.text.len() > 40 {
1657                    format!("{}...", &tf.text[..40])
1658                } else {
1659                    tf.text.clone()
1660                };
1661                format!("TextFrame: {}", text)
1662            }
1663            SchRecord::Parameter(p) => format!("Parameter: {} = {}", p.name, p.label.text),
1664            SchRecord::Designator(d) => {
1665                format!("Designator: {} = {}", d.param.name, d.param.label.text)
1666            }
1667            _ => record_type_name(record).to_string(),
1668        };
1669        println!("  [{}] {}", id.index(), desc);
1670    }
1671
1672    if results.len() >= max_results {
1673        println!("\n(results limited to {})", max_results);
1674    }
1675
1676    Ok(())
1677}
1678
1679/// List junctions.
1680pub fn cmd_junctions(path: &Path) -> Result<SchDocJunctionList, Box<dyn std::error::Error>> {
1681    let doc = open_schdoc(path)?;
1682
1683    let junctions: Vec<JunctionInfo> = doc
1684        .primitives
1685        .iter()
1686        .filter_map(|r| {
1687            if let SchRecord::Junction(j) = r {
1688                Some(JunctionInfo {
1689                    location: fmt_point(j.graphical.location_x, j.graphical.location_y),
1690                })
1691            } else {
1692                None
1693            }
1694        })
1695        .collect();
1696
1697    Ok(SchDocJunctionList {
1698        path: path.display().to_string(),
1699        total_junctions: junctions.len(),
1700        junctions,
1701    })
1702}
1703
1704/// JSON export structures.
1705#[derive(Serialize)]
1706struct JsonDocument {
1707    file: String,
1708    sheet: Option<JsonSheet>,
1709    summary: JsonSummary,
1710    #[serde(skip_serializing_if = "Option::is_none")]
1711    components: Option<Vec<JsonComponent>>,
1712    #[serde(skip_serializing_if = "Option::is_none")]
1713    nets: Option<Vec<JsonNet>>,
1714    #[serde(skip_serializing_if = "Option::is_none")]
1715    ports: Option<Vec<JsonPort>>,
1716    #[serde(skip_serializing_if = "Option::is_none")]
1717    power: Option<Vec<JsonPower>>,
1718}
1719
1720#[derive(Serialize)]
1721struct JsonSheet {
1722    size: String,
1723    fonts: i32,
1724}
1725
1726#[derive(Serialize)]
1727struct JsonSummary {
1728    total_primitives: usize,
1729    components: usize,
1730    wires: usize,
1731    net_labels: usize,
1732    ports: usize,
1733    power_objects: usize,
1734    junctions: usize,
1735    pins: usize,
1736}
1737
1738#[derive(Serialize)]
1739struct JsonComponent {
1740    designator: String,
1741    lib_reference: String,
1742    description: String,
1743    location: String,
1744    pins: Vec<JsonPin>,
1745    parameters: Vec<JsonParameter>,
1746}
1747
1748#[derive(Serialize)]
1749struct JsonPin {
1750    designator: String,
1751    name: String,
1752    electrical: String,
1753    hidden: bool,
1754}
1755
1756#[derive(Serialize)]
1757struct JsonParameter {
1758    name: String,
1759    value: String,
1760}
1761
1762#[derive(Serialize)]
1763struct JsonNet {
1764    name: String,
1765    location: String,
1766}
1767
1768#[derive(Serialize)]
1769struct JsonPort {
1770    name: String,
1771    io_type: String,
1772    location: String,
1773}
1774
1775#[derive(Serialize)]
1776struct JsonPower {
1777    net: String,
1778    style: String,
1779    location: String,
1780}
1781
1782/// Export as JSON.
1783pub fn cmd_json(path: &Path, full: bool, pretty: bool) -> Result<(), String> {
1784    let doc = open_schdoc(path)?;
1785    let tree = RecordTree::from_records(doc.primitives.clone());
1786    let counts = count_record_types(&doc);
1787
1788    // Build sheet info
1789    let sheet = doc.sheet_header().map(|h| JsonSheet {
1790        size: sheet_size_name(h.sheet_size).to_string(),
1791        fonts: h.font_id_count,
1792    });
1793
1794    // Build summary
1795    let summary = JsonSummary {
1796        total_primitives: doc.primitives.len(),
1797        components: counts.get("Component").copied().unwrap_or(0),
1798        wires: counts.get("Wire").copied().unwrap_or(0),
1799        net_labels: counts.get("NetLabel").copied().unwrap_or(0),
1800        ports: counts.get("Port").copied().unwrap_or(0),
1801        power_objects: counts.get("PowerObject").copied().unwrap_or(0),
1802        junctions: counts.get("Junction").copied().unwrap_or(0),
1803        pins: counts.get("Pin").copied().unwrap_or(0),
1804    };
1805
1806    // Full export includes all components, nets, ports, power
1807    let (components, nets, ports, power) = if full {
1808        // Components with their pins and parameters
1809        let mut components = Vec::new();
1810        for (id, record) in tree.iter() {
1811            if let SchRecord::Component(c) = record {
1812                let des = get_component_designator(&tree, id).unwrap_or_default();
1813
1814                let mut pins = Vec::new();
1815                let mut params = Vec::new();
1816
1817                for (_, child) in tree.children(id) {
1818                    match child {
1819                        SchRecord::Pin(p) => {
1820                            pins.push(JsonPin {
1821                                designator: p.designator.clone(),
1822                                name: p.name.clone(),
1823                                electrical: format!("{:?}", p.electrical),
1824                                hidden: p.is_hidden(),
1825                            });
1826                        }
1827                        SchRecord::Parameter(p) => {
1828                            params.push(JsonParameter {
1829                                name: p.name.clone(),
1830                                value: p.label.text.clone(),
1831                            });
1832                        }
1833                        _ => {}
1834                    }
1835                }
1836
1837                components.push(JsonComponent {
1838                    designator: des,
1839                    lib_reference: c.lib_reference.clone(),
1840                    description: c.component_description.clone(),
1841                    location: fmt_point(c.graphical.location_x, c.graphical.location_y),
1842                    pins,
1843                    parameters: params,
1844                });
1845            }
1846        }
1847
1848        // Net labels
1849        let nets: Vec<JsonNet> = doc
1850            .primitives
1851            .iter()
1852            .filter_map(|r| {
1853                if let SchRecord::NetLabel(nl) = r {
1854                    Some(JsonNet {
1855                        name: nl.label.text.clone(),
1856                        location: fmt_point(
1857                            nl.label.graphical.location_x,
1858                            nl.label.graphical.location_y,
1859                        ),
1860                    })
1861                } else {
1862                    None
1863                }
1864            })
1865            .collect();
1866
1867        // Ports
1868        let ports: Vec<JsonPort> = doc
1869            .primitives
1870            .iter()
1871            .filter_map(|r| {
1872                if let SchRecord::Port(p) = r {
1873                    Some(JsonPort {
1874                        name: p.name.clone(),
1875                        io_type: format!("{:?}", p.io_type),
1876                        location: fmt_point(p.graphical.location_x, p.graphical.location_y),
1877                    })
1878                } else {
1879                    None
1880                }
1881            })
1882            .collect();
1883
1884        // Power objects
1885        let power: Vec<JsonPower> = doc
1886            .primitives
1887            .iter()
1888            .filter_map(|r| {
1889                if let SchRecord::PowerObject(p) = r {
1890                    Some(JsonPower {
1891                        net: p.text.clone(),
1892                        style: format!("{:?}", p.style),
1893                        location: fmt_point(p.graphical.location_x, p.graphical.location_y),
1894                    })
1895                } else {
1896                    None
1897                }
1898            })
1899            .collect();
1900
1901        (Some(components), Some(nets), Some(ports), Some(power))
1902    } else {
1903        (None, None, None, None)
1904    };
1905
1906    let json_doc = JsonDocument {
1907        file: path.display().to_string(),
1908        sheet,
1909        summary,
1910        components,
1911        nets,
1912        ports,
1913        power,
1914    };
1915
1916    let output = if pretty {
1917        serde_json::to_string_pretty(&json_doc)
1918    } else {
1919        serde_json::to_string(&json_doc)
1920    }
1921    .map_err(|e| format!("JSON serialization error: {}", e))?;
1922
1923    println!("{}", output);
1924
1925    Ok(())
1926}
1927
1928// ═══════════════════════════════════════════════════════════════════════════
1929// EDITING COMMAND IMPLEMENTATIONS (re-exported from schdoc_edit)
1930// ═══════════════════════════════════════════════════════════════════════════
1931
1932pub use crate::ops::schdoc_edit::{
1933    cmd_add_component, cmd_add_junction, cmd_add_missing_junctions, cmd_add_net_label,
1934    cmd_add_port, cmd_add_power, cmd_add_wire, cmd_connect_pins, cmd_delete_component,
1935    cmd_delete_wire, cmd_find_missing_junctions, cmd_find_unconnected, cmd_list_library,
1936    cmd_move_component, cmd_new, cmd_route_wire, cmd_search_library, cmd_show_netlist,
1937    cmd_suggest_placement, cmd_validate,
1938};