Skip to main content

altium_format/ops/
schdoc_edit.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// SPDX-FileCopyrightText: 2026 Alexander Kiselev <alex@akiselev.com>
3//
4//! Schematic document editing commands.
5//!
6//! This module contains editing-related commands for schematic documents that
7//! modify the document content. Extracted from schdoc.rs for modularity.
8
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::edit::{EditSession, Orientation};
14use crate::ops::output::*;
15use crate::records::sch::{PortIoType, PowerObjectStyle, SchRecord, TextOrientations};
16use crate::types::{Coord, CoordPoint, Unit};
17
18/// Parse a unit value or interpret as mils.
19fn parse_unit_value_or_mil(s: &str) -> Result<f64, String> {
20    let s = s.trim();
21
22    // Try parsing with unit suffix first
23    if let Ok((coord, unit)) = Unit::parse_with_unit(s) {
24        // If the parser detected a unit suffix (not DxpDefault which means no suffix), use it
25        if unit != Unit::DxpDefault {
26            return Ok(coord.to_mils());
27        }
28    }
29
30    // Plain number (no unit suffix) - interpret as mils
31    s.parse::<f64>().map_err(|_| {
32        format!(
33            "Invalid value '{}': expected number with optional unit (e.g., '100mil', '2.54mm')",
34            s
35        )
36    })
37}
38
39/// Create a new empty schematic document.
40pub fn cmd_new(output: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
41    let session = EditSession::new();
42
43    match output {
44        Some(path) => {
45            session.doc.save_to_file(&path)?;
46            println!("Created new schematic: {}", path.display());
47        }
48        None => {
49            println!("Created new empty schematic in memory.");
50            println!("Use --output to save to a file.");
51        }
52    }
53
54    Ok(())
55}
56
57/// Validate a schematic document.
58pub fn cmd_validate(path: &Path) -> Result<SchDocValidationResult, Box<dyn std::error::Error>> {
59    let session = EditSession::open(path)?;
60
61    let validation_errors = session.validate();
62
63    let errors = validation_errors
64        .iter()
65        .map(|e| ValidationError {
66            kind: format!("{:?}", e.kind),
67            message: e.message.clone(),
68            location: e.location.map(|l| (l.x.to_mils(), l.y.to_mils())),
69            components: e.components.clone(),
70        })
71        .collect();
72
73    Ok(SchDocValidationResult {
74        path: path.display().to_string(),
75        is_valid: validation_errors.is_empty(),
76        errors,
77    })
78}
79
80/// Add a component from a library to the schematic.
81#[allow(clippy::too_many_arguments)]
82pub fn cmd_add_component(
83    path: &Path,
84    library: &Path,
85    component: &str,
86    x: &str,
87    y: &str,
88    designator: Option<&str>,
89    rotation: i32,
90    output: Option<PathBuf>,
91) -> Result<(), Box<dyn std::error::Error>> {
92    let x_mils = parse_unit_value_or_mil(x)?;
93    let y_mils = parse_unit_value_or_mil(y)?;
94
95    let mut session = EditSession::open(path)?;
96
97    session.load_library(library)?;
98
99    let location = CoordPoint::from_mils(x_mils, y_mils);
100    let orientation = match rotation {
101        0 => Orientation::Normal,
102        90 => Orientation::Rotated90,
103        180 => Orientation::Rotated180,
104        270 => Orientation::Rotated270,
105        _ => return Err(format!("Invalid rotation: {}. Use 0, 90, 180, or 270.", rotation).into()),
106    };
107
108    let _index = session.add_component(component, location, orientation, designator)?;
109
110    let output_path = output.as_deref().unwrap_or(path);
111    session.save(output_path)?;
112
113    let des = designator.unwrap_or("(auto)");
114    println!(
115        "Added component {} at ({:.0}, {:.0}) mils as {}",
116        component, x_mils, y_mils, des
117    );
118    println!("Saved to: {}", output_path.display());
119
120    Ok(())
121}
122
123/// Suggest placement locations for a component.
124pub fn cmd_suggest_placement(
125    path: &Path,
126    library: &Path,
127    component: &str,
128    near: Option<String>,
129    json: bool,
130) -> Result<(), Box<dyn std::error::Error>> {
131    let mut session = EditSession::open(path)?;
132
133    session.load_library(library)?;
134
135    let suggestions = session.suggest_component_placement(component, near.as_deref());
136
137    if json {
138        #[derive(Serialize)]
139        struct Suggestion {
140            x: f64,
141            y: f64,
142            rotation: i32,
143            score: f64,
144            reason: String,
145        }
146
147        let result: Vec<_> = suggestions
148            .iter()
149            .map(|s| Suggestion {
150                x: s.location.x.to_mils(),
151                y: s.location.y.to_mils(),
152                rotation: (s.orientation.rotation_degrees() as i32) % 360,
153                score: s.score,
154                reason: s.reason.clone(),
155            })
156            .collect();
157
158        println!(
159            "{}",
160            serde_json::to_string_pretty(&result)
161                .map_err(|e| format!("JSON serialization error: {}", e))?
162        );
163    } else {
164        println!("Placement suggestions for '{}':", component);
165        if suggestions.is_empty() {
166            println!("  No suitable locations found.");
167        } else {
168            for (i, s) in suggestions.iter().enumerate() {
169                println!(
170                    "  {}. ({:.0}, {:.0}) mils - score: {:.2} - {}",
171                    i + 1,
172                    s.location.x.to_mils(),
173                    s.location.y.to_mils(),
174                    s.score,
175                    s.reason
176                );
177            }
178        }
179    }
180
181    Ok(())
182}
183
184/// Move a component to a new location.
185pub fn cmd_move_component(
186    path: &Path,
187    designator: &str,
188    x: &str,
189    y: &str,
190    output: Option<PathBuf>,
191) -> Result<(), Box<dyn std::error::Error>> {
192    let x_mils = parse_unit_value_or_mil(x)?;
193    let y_mils = parse_unit_value_or_mil(y)?;
194
195    let mut session = EditSession::open(path)?;
196
197    // Find component by designator
198    let components = session
199        .layout()
200        .get_placed_components(&session.doc.primitives);
201    let comp = components
202        .iter()
203        .find(|c| c.designator == designator)
204        .ok_or_else(|| format!("Component not found: {}", designator))?;
205
206    let index = comp.index;
207    let new_location = CoordPoint::from_mils(x_mils, y_mils);
208
209    session.move_component(index, new_location)?;
210
211    let output_path = output.as_deref().unwrap_or(path);
212    session.save(output_path)?;
213
214    println!(
215        "Moved {} to ({:.0}, {:.0}) mils",
216        designator, x_mils, y_mils
217    );
218    println!("Saved to: {}", output_path.display());
219
220    Ok(())
221}
222
223/// Delete a component from the schematic.
224pub fn cmd_delete_component(
225    path: &Path,
226    designator: &str,
227    output: Option<PathBuf>,
228) -> Result<(), Box<dyn std::error::Error>> {
229    let mut session = EditSession::open(path)?;
230
231    // Find component by designator
232    let components = session
233        .layout()
234        .get_placed_components(&session.doc.primitives);
235    let comp = components
236        .iter()
237        .find(|c| c.designator == designator)
238        .ok_or_else(|| format!("Component not found: {}", designator))?;
239
240    let index = comp.index;
241
242    session.delete_component(index)?;
243
244    let output_path = output.as_deref().unwrap_or(path);
245    session.save(output_path)?;
246
247    println!("Deleted component {}", designator);
248    println!("Saved to: {}", output_path.display());
249
250    Ok(())
251}
252
253/// Add a wire with specified vertices.
254pub fn cmd_add_wire(
255    path: &Path,
256    vertices_str: &str,
257    output: Option<PathBuf>,
258) -> Result<(), Box<dyn std::error::Error>> {
259    let mut session = EditSession::open(path)?;
260
261    // Parse vertices from string
262    let values: Vec<f64> = vertices_str
263        .split(',')
264        .map(|s| s.trim().parse::<f64>())
265        .collect::<Result<Vec<_>, _>>()
266        .map_err(|e| format!("Invalid vertex format: {}", e))?;
267
268    if values.len() < 4 || values.len() % 2 != 0 {
269        return Err(
270            "Vertices must be pairs of X,Y coordinates (at least 2 points)"
271                .to_string()
272                .into(),
273        );
274    }
275
276    let vertices: Vec<CoordPoint> = values
277        .chunks(2)
278        .map(|chunk| CoordPoint::from_mils(chunk[0], chunk[1]))
279        .collect();
280
281    session.add_wire(&vertices)?;
282
283    let output_path = output.as_deref().unwrap_or(path);
284    session.save(output_path)?;
285
286    println!("Added wire with {} vertices", vertices.len());
287    println!("Saved to: {}", output_path.display());
288
289    Ok(())
290}
291
292/// Parse a point specification for routing targets.
293fn parse_point_spec(spec: &str, session: &EditSession) -> Result<(CoordPoint, String), String> {
294    // Try parsing as coordinates first (x,y format)
295    if let Some((x_str, y_str)) = spec.split_once(',') {
296        let x: f64 = x_str.trim().parse().map_err(|_| {
297            format!(
298                "Invalid x coordinate in '{}'. Use: Component.Pin, :Port, %NetLabel, @Power, or x,y",
299                spec
300            )
301        })?;
302        let y: f64 = y_str.trim().parse().map_err(|_| {
303            format!(
304                "Invalid y coordinate in '{}'. Use: Component.Pin, :Port, %NetLabel, @Power, or x,y",
305                spec
306            )
307        })?;
308        let point = CoordPoint::from_mils(x, y);
309        return Ok((point, format!("({:.0}, {:.0})", x, y)));
310    }
311
312    // Try parsing as port reference (:PortName format)
313    if let Some(port_name) = spec.strip_prefix(':') {
314        let ports: Vec<_> = session
315            .doc
316            .primitives
317            .iter()
318            .filter_map(|p| match p {
319                SchRecord::Port(port) => Some(port),
320                _ => None,
321            })
322            .collect();
323
324        let port = ports.iter().find(|p| p.name == port_name).ok_or_else(|| {
325            format!(
326                "Port not found: '{}'. Available ports: {:?}",
327                port_name,
328                ports.iter().map(|p| &p.name).collect::<Vec<_>>()
329            )
330        })?;
331
332        let point = CoordPoint::new(
333            Coord::from_raw(port.graphical.location_x),
334            Coord::from_raw(port.graphical.location_y),
335        );
336        return Ok((point, format!(":{}", port_name)));
337    }
338
339    // Try parsing as net label reference (%NetLabel format)
340    if let Some(label_name) = spec.strip_prefix('%') {
341        let labels: Vec<_> = session
342            .doc
343            .primitives
344            .iter()
345            .filter_map(|p| match p {
346                SchRecord::NetLabel(label) => Some(label),
347                _ => None,
348            })
349            .collect();
350
351        let label = labels
352            .iter()
353            .find(|l| l.label.text == label_name)
354            .ok_or_else(|| {
355                format!(
356                    "Net label not found: '{}'. Available net labels: {:?}",
357                    label_name,
358                    labels.iter().map(|l| &l.label.text).collect::<Vec<_>>()
359                )
360            })?;
361
362        let point = CoordPoint::new(
363            Coord::from_raw(label.label.graphical.location_x),
364            Coord::from_raw(label.label.graphical.location_y),
365        );
366        return Ok((point, format!("%{}", label_name)));
367    }
368
369    // Try parsing as power port reference (@PowerPort format)
370    if let Some(power_name) = spec.strip_prefix('@') {
371        let powers: Vec<_> = session
372            .doc
373            .primitives
374            .iter()
375            .filter_map(|p| match p {
376                SchRecord::PowerObject(power) => Some(power),
377                _ => None,
378            })
379            .collect();
380
381        let power = powers
382            .iter()
383            .find(|p| p.text == power_name)
384            .ok_or_else(|| {
385                format!(
386                    "Power port not found: '{}'. Available power ports: {:?}",
387                    power_name,
388                    powers.iter().map(|p| &p.text).collect::<Vec<_>>()
389                )
390            })?;
391
392        let point = CoordPoint::new(
393            Coord::from_raw(power.graphical.location_x),
394            Coord::from_raw(power.graphical.location_y),
395        );
396        return Ok((point, format!("@{}", power_name)));
397    }
398
399    // Try parsing as Component.Pin reference
400    if let Some((component, pin)) = spec.split_once('.') {
401        let components = session
402            .layout()
403            .get_placed_components(&session.doc.primitives);
404        let comp = components
405            .iter()
406            .find(|c| c.designator == component)
407            .ok_or_else(|| {
408                format!(
409                    "Component not found: '{}'. Available components: {:?}",
410                    component,
411                    components.iter().map(|c| &c.designator).collect::<Vec<_>>()
412                )
413            })?;
414
415        let pin_loc = comp
416            .pin_locations
417            .iter()
418            .find(|p| p.designator == pin || p.name == pin)
419            .ok_or_else(|| {
420                format!(
421                    "Pin '{}' not found on component '{}'. Available pins: {:?}",
422                    pin,
423                    component,
424                    comp.pin_locations
425                        .iter()
426                        .map(|p| format!("{}/{}", p.designator, p.name))
427                        .collect::<Vec<_>>()
428                )
429            })?;
430
431        return Ok((pin_loc.location, format!("{}.{}", component, pin)));
432    }
433
434    Err(format!(
435        "Invalid point specification: '{}'. Expected: Component.Pin, :Port, %NetLabel, @Power, or x,y",
436        spec
437    ))
438}
439
440/// Route a wire between two points.
441pub fn cmd_route_wire(
442    path: &Path,
443    from: &str,
444    to: &str,
445    output: Option<PathBuf>,
446) -> Result<(), Box<dyn std::error::Error>> {
447    let mut session = EditSession::open(path)?;
448
449    let (start, from_desc) = parse_point_spec(from, &session)?;
450    let (end, to_desc) = parse_point_spec(to, &session)?;
451
452    session.route_wire(start, end)?;
453
454    let output_path = output.as_deref().unwrap_or(path);
455    session.save(output_path)?;
456
457    println!("Routed wire from {} to {}", from_desc, to_desc);
458    println!("Saved to: {}", output_path.display());
459
460    Ok(())
461}
462
463/// Connect two pins with a wire.
464pub fn cmd_connect_pins(
465    path: &Path,
466    from_component: &str,
467    from_pin: &str,
468    to_component: &str,
469    to_pin: &str,
470    output: Option<PathBuf>,
471) -> Result<(), Box<dyn std::error::Error>> {
472    let mut session = EditSession::open(path)?;
473
474    session.connect_pins(from_component, from_pin, to_component, to_pin)?;
475
476    let output_path = output.as_deref().unwrap_or(path);
477    session.save(output_path)?;
478
479    println!(
480        "Connected {}.{} to {}.{}",
481        from_component, from_pin, to_component, to_pin
482    );
483    println!("Saved to: {}", output_path.display());
484
485    Ok(())
486}
487
488/// Delete a wire by index.
489pub fn cmd_delete_wire(
490    path: &Path,
491    index: usize,
492    output: Option<PathBuf>,
493) -> Result<(), Box<dyn std::error::Error>> {
494    let mut session = EditSession::open(path)?;
495
496    session.delete_wire(index)?;
497
498    let output_path = output.as_deref().unwrap_or(path);
499    session.save(output_path)?;
500
501    println!("Deleted wire at index {}", index);
502    println!("Saved to: {}", output_path.display());
503
504    Ok(())
505}
506
507/// Add a net label at a location.
508pub fn cmd_add_net_label(
509    path: &Path,
510    name: &str,
511    x: &str,
512    y: &str,
513    output: Option<PathBuf>,
514) -> Result<(), Box<dyn std::error::Error>> {
515    let x_mils = parse_unit_value_or_mil(x)?;
516    let y_mils = parse_unit_value_or_mil(y)?;
517
518    let mut session = EditSession::open(path)?;
519
520    let location = CoordPoint::from_mils(x_mils, y_mils);
521
522    session.add_net_label(name, location)?;
523
524    let output_path = output.as_deref().unwrap_or(path);
525    session.save(output_path)?;
526
527    println!(
528        "Added net label '{}' at ({:.0}, {:.0}) mils",
529        name, x_mils, y_mils
530    );
531    println!("Saved to: {}", output_path.display());
532
533    Ok(())
534}
535
536/// Smart-wire: create a wire stub from a pin and attach a net label or power port.
537pub fn cmd_smart_wire(
538    path: &Path,
539    component: &str,
540    pin: &str,
541    net: &str,
542    power_style: Option<&str>,
543    wire_length_mils: f64,
544    output: Option<PathBuf>,
545) -> Result<(), Box<dyn std::error::Error>> {
546    let mut session = EditSession::open(path)?;
547
548    let style = match power_style {
549        Some(s) => {
550            let parsed = match s.to_lowercase().as_str() {
551                "bar" | "power_bar" => PowerObjectStyle::Bar,
552                "arrow" => PowerObjectStyle::Arrow,
553                "wave" => PowerObjectStyle::Wave,
554                "ground" | "gnd" => PowerObjectStyle::Ground,
555                "power_ground" | "pgnd" => PowerObjectStyle::PowerGround,
556                "signal_ground" | "sgnd" => PowerObjectStyle::SignalGround,
557                "earth_ground" | "earth" => PowerObjectStyle::EarthGround,
558                "circle" => PowerObjectStyle::Circle,
559                _ => {
560                    return Err(format!(
561                        "Unknown power style: {}. Use: bar, arrow, wave, ground, power_ground, signal_ground, earth_ground, circle",
562                        s
563                    ).into());
564                }
565            };
566            Some(parsed)
567        }
568        None => None,
569    };
570
571    let (wire_idx, label_idx) = session.smart_wire_pin(
572        component,
573        pin,
574        net,
575        style,
576        wire_length_mils,
577    )?;
578
579    let output_path = output.as_deref().unwrap_or(path);
580    session.save(output_path)?;
581
582    let kind = if power_style.is_some() { "power port" } else { "net label" };
583    println!(
584        "Smart-wired {}.{} -> {} '{}' (wire #{}, {} #{})",
585        component, pin, kind, net, wire_idx, kind, label_idx
586    );
587    println!("Saved to: {}", output_path.display());
588
589    Ok(())
590}
591
592/// Batch smart-wire: wire multiple pins from a mapping string.
593///
594/// Format: "COMP.PIN=NET,COMP.PIN=NET:power_style,..."
595/// Examples: "U1.3=VCC:bar,U1.4=GND:ground,U1.5=SDA,U1.6=SCL"
596pub fn cmd_smart_wire_batch(
597    path: &Path,
598    mappings: &str,
599    wire_length_mils: f64,
600    output: Option<PathBuf>,
601) -> Result<(), Box<dyn std::error::Error>> {
602    let mut session = EditSession::open(path)?;
603
604    let mut count = 0;
605    for mapping in mappings.split(',') {
606        let mapping = mapping.trim();
607        if mapping.is_empty() {
608            continue;
609        }
610
611        // Parse "COMP.PIN=NET" or "COMP.PIN=NET:power_style"
612        let (pin_spec, net_spec) = mapping
613            .split_once('=')
614            .ok_or_else(|| format!("Invalid mapping '{}': expected COMP.PIN=NET", mapping))?;
615
616        let (component, pin) = pin_spec
617            .split_once('.')
618            .ok_or_else(|| format!("Invalid pin spec '{}': expected COMP.PIN", pin_spec))?;
619
620        let (net, power_style) = if let Some((n, s)) = net_spec.split_once(':') {
621            let parsed = match s.to_lowercase().as_str() {
622                "bar" | "power_bar" => PowerObjectStyle::Bar,
623                "arrow" => PowerObjectStyle::Arrow,
624                "wave" => PowerObjectStyle::Wave,
625                "ground" | "gnd" => PowerObjectStyle::Ground,
626                "power_ground" | "pgnd" => PowerObjectStyle::PowerGround,
627                "signal_ground" | "sgnd" => PowerObjectStyle::SignalGround,
628                "earth_ground" | "earth" => PowerObjectStyle::EarthGround,
629                "circle" => PowerObjectStyle::Circle,
630                _ => {
631                    return Err(format!(
632                        "Unknown power style '{}' in mapping '{}'. Use: bar, arrow, wave, ground, power_ground, signal_ground, earth_ground, circle",
633                        s, mapping
634                    ).into());
635                }
636            };
637            (n, Some(parsed))
638        } else {
639            (net_spec, None)
640        };
641
642        session.smart_wire_pin(component, pin, net, power_style, wire_length_mils)?;
643        count += 1;
644
645        let kind = if power_style.is_some() { "power" } else { "net" };
646        println!("  {}.{} -> {} '{}'", component, pin, kind, net);
647    }
648
649    let output_path = output.as_deref().unwrap_or(path);
650    session.save(output_path)?;
651
652    println!("Smart-wired {} pins. Saved to: {}", count, output_path.display());
653
654    Ok(())
655}
656
657/// Add a power port.
658pub fn cmd_add_power(
659    path: &Path,
660    name: &str,
661    x: &str,
662    y: &str,
663    style: &str,
664    orientation: &str,
665    output: Option<PathBuf>,
666) -> Result<(), Box<dyn std::error::Error>> {
667    let x_mils = parse_unit_value_or_mil(x)?;
668    let y_mils = parse_unit_value_or_mil(y)?;
669
670    let mut session = EditSession::open(path)?;
671
672    let location = CoordPoint::from_mils(x_mils, y_mils);
673
674    let power_style = match style.to_lowercase().as_str() {
675        "bar" | "power_bar" => PowerObjectStyle::Bar,
676        "arrow" => PowerObjectStyle::Arrow,
677        "wave" => PowerObjectStyle::Wave,
678        "ground" | "gnd" => PowerObjectStyle::Ground,
679        "power_ground" | "pgnd" => PowerObjectStyle::PowerGround,
680        "signal_ground" | "sgnd" => PowerObjectStyle::SignalGround,
681        "earth_ground" | "earth" => PowerObjectStyle::EarthGround,
682        "circle" => PowerObjectStyle::Circle,
683        _ => {
684            return Err(format!(
685                "Unknown power style: {}. Use: bar, arrow, wave, ground, power_ground, signal_ground, earth_ground, circle",
686                style
687            )
688            .into())
689        }
690    };
691
692    // TextOrientations is a bitflags struct: NONE=0, ROTATED=1, FLIPPED=2
693    let orient = match orientation.to_lowercase().as_str() {
694        "up" | "0" => TextOrientations::NONE,
695        "left" | "90" => TextOrientations::ROTATED,
696        "down" | "180" => TextOrientations::FLIPPED,
697        "right" | "270" => TextOrientations::ROTATED | TextOrientations::FLIPPED,
698        _ => {
699            return Err(format!(
700                "Unknown orientation: {}. Use: up, down, left, right",
701                orientation
702            )
703            .into());
704        }
705    };
706
707    session.add_power_port(name, location, power_style, orient)?;
708
709    let output_path = output.as_deref().unwrap_or(path);
710    session.save(output_path)?;
711
712    println!(
713        "Added power port '{}' ({}) at ({:.0}, {:.0}) mils",
714        name, style, x_mils, y_mils
715    );
716    println!("Saved to: {}", output_path.display());
717
718    Ok(())
719}
720
721/// Add a junction at a location.
722pub fn cmd_add_junction(
723    path: &Path,
724    x: &str,
725    y: &str,
726    output: Option<PathBuf>,
727) -> Result<(), Box<dyn std::error::Error>> {
728    let x_mils = parse_unit_value_or_mil(x)?;
729    let y_mils = parse_unit_value_or_mil(y)?;
730
731    let mut session = EditSession::open(path)?;
732
733    let location = CoordPoint::from_mils(x_mils, y_mils);
734
735    session.add_junction(location)?;
736
737    let output_path = output.as_deref().unwrap_or(path);
738    session.save(output_path)?;
739
740    println!("Added junction at ({:.0}, {:.0}) mils", x_mils, y_mils);
741    println!("Saved to: {}", output_path.display());
742
743    Ok(())
744}
745
746/// Add missing junctions where wires cross.
747pub fn cmd_add_missing_junctions(
748    path: &Path,
749    output: Option<PathBuf>,
750) -> Result<(), Box<dyn std::error::Error>> {
751    let mut session = EditSession::open(path)?;
752
753    let count = session.add_missing_junctions()?;
754
755    let output_path = output.as_deref().unwrap_or(path);
756    session.save(output_path)?;
757
758    println!("Added {} missing junction(s)", count);
759    println!("Saved to: {}", output_path.display());
760
761    Ok(())
762}
763
764/// Add a port at a location.
765pub fn cmd_add_port(
766    path: &Path,
767    name: &str,
768    x: &str,
769    y: &str,
770    io_type: &str,
771    output: Option<PathBuf>,
772) -> Result<(), Box<dyn std::error::Error>> {
773    let x_mils = parse_unit_value_or_mil(x)?;
774    let y_mils = parse_unit_value_or_mil(y)?;
775
776    let mut session = EditSession::open(path)?;
777
778    let location = CoordPoint::from_mils(x_mils, y_mils);
779
780    let port_io_type = match io_type.to_lowercase().as_str() {
781        "input" | "in" => PortIoType::Input,
782        "output" | "out" => PortIoType::Output,
783        "bidirectional" | "bidir" | "inout" => PortIoType::Bidirectional,
784        "unspecified" | "none" => PortIoType::Unspecified,
785        _ => {
786            return Err(format!(
787                "Unknown I/O type: {}. Use: input, output, bidirectional, unspecified",
788                io_type
789            )
790            .into());
791        }
792    };
793
794    session.add_port(name, location, port_io_type)?;
795
796    let output_path = output.as_deref().unwrap_or(path);
797    session.save(output_path)?;
798
799    println!(
800        "Added port '{}' ({}) at ({:.0}, {:.0}) mils",
801        name, io_type, x_mils, y_mils
802    );
803    println!("Saved to: {}", output_path.display());
804
805    Ok(())
806}
807
808/// Show the netlist for a schematic.
809pub fn cmd_show_netlist(path: &Path, filter_net: Option<&str>, json: bool) -> Result<(), String> {
810    let session = EditSession::open(path).map_err(|e| format!("Failed to open: {:?}", e))?;
811
812    let netlist = session.build_netlist();
813
814    if json {
815        #[derive(Serialize)]
816        struct NetJson {
817            name: String,
818            named: bool,
819            pins: Vec<(String, String)>,
820        }
821
822        let nets: Vec<_> = netlist
823            .nets
824            .iter()
825            .filter(|n| filter_net.map(|f| n.name.contains(f)).unwrap_or(true))
826            .map(|n| NetJson {
827                name: n.name.clone(),
828                named: n.named,
829                pins: n
830                    .pins()
831                    .iter()
832                    .map(|(c, p)| (c.to_string(), p.to_string()))
833                    .collect(),
834            })
835            .collect();
836
837        println!(
838            "{}",
839            serde_json::to_string_pretty(&nets)
840                .map_err(|e| format!("JSON serialization error: {}", e))?
841        );
842    } else {
843        println!("Netlist ({} nets):", netlist.nets.len());
844        println!();
845
846        for net in &netlist.nets {
847            if let Some(filter) = filter_net {
848                if !net.name.contains(filter) {
849                    continue;
850                }
851            }
852
853            let pins = net.pins();
854            let named_str = if net.named { "" } else { " (auto)" };
855            println!("  {} [{}]{}", net.name, pins.len(), named_str);
856
857            for (comp, pin) in pins {
858                println!("    - {}.{}", comp, pin);
859            }
860        }
861    }
862
863    Ok(())
864}
865
866/// Find unconnected pins in the schematic.
867pub fn cmd_find_unconnected(
868    path: &Path,
869) -> Result<SchDocUnconnectedPins, Box<dyn std::error::Error>> {
870    let session = EditSession::open(path)?;
871
872    let unconnected = session.find_unconnected_pins();
873
874    let pins = unconnected
875        .iter()
876        .map(|(c, p, l)| UnconnectedPin {
877            component: c.clone(),
878            pin: p.clone(),
879            x: l.0 as f64 / 10000.0,
880            y: l.1 as f64 / 10000.0,
881        })
882        .collect();
883
884    Ok(SchDocUnconnectedPins {
885        path: path.display().to_string(),
886        total_unconnected: unconnected.len(),
887        pins,
888    })
889}
890
891/// Find missing junctions in the schematic.
892pub fn cmd_find_missing_junctions(
893    path: &Path,
894) -> Result<SchDocMissingJunctions, Box<dyn std::error::Error>> {
895    let session = EditSession::open(path)?;
896
897    let missing = session.find_missing_junctions();
898
899    let locations: Vec<(f64, f64)> = missing
900        .iter()
901        .map(|l| (l.0 as f64 / 10000.0, l.1 as f64 / 10000.0))
902        .collect();
903
904    Ok(SchDocMissingJunctions {
905        path: path.display().to_string(),
906        total_missing: missing.len(),
907        locations,
908    })
909}
910
911/// Search a schematic library for components matching a pattern.
912pub fn cmd_search_library(
913    library: &Path,
914    pattern: &str,
915) -> Result<SchDocLibrarySearchResults, Box<dyn std::error::Error>> {
916    use crate::io::SchLib;
917
918    let lib = SchLib::open_file(library)?;
919
920    let pattern_lower = pattern.to_lowercase();
921    let matches: Vec<_> = lib
922        .iter()
923        .filter(|c| {
924            c.name().to_lowercase().contains(&pattern_lower)
925                || c.description().to_lowercase().contains(&pattern_lower)
926        })
927        .collect();
928
929    let match_results: Vec<LibraryComponentMatch> = matches
930        .iter()
931        .map(|c| LibraryComponentMatch {
932            name: c.name().to_string(),
933            description: c.description().to_string(),
934            pins: c.pin_count(),
935        })
936        .collect();
937
938    Ok(SchDocLibrarySearchResults {
939        library: library.display().to_string(),
940        pattern: pattern.to_string(),
941        total_matches: matches.len(),
942        matches: match_results,
943    })
944}
945
946/// List components in a schematic library.
947pub fn cmd_list_library(
948    library: &Path,
949    verbose: bool,
950) -> Result<SchDocLibraryList, Box<dyn std::error::Error>> {
951    use crate::io::SchLib;
952
953    let lib = SchLib::open_file(library)?;
954
955    let components: Vec<LibraryComponentInfo> = lib
956        .iter()
957        .map(|c| LibraryComponentInfo {
958            name: c.name().to_string(),
959            description: c.description().to_string(),
960            pins: c.pin_count(),
961            primitives: if verbose {
962                Some(c.primitive_count())
963            } else {
964                None
965            },
966        })
967        .collect();
968
969    Ok(SchDocLibraryList {
970        library: library.display().to_string(),
971        total_components: lib.component_count(),
972        components,
973    })
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use crate::ops::schlib::{cmd_create as schlib_create, cmd_gen_ic};
980    use std::path::PathBuf;
981
982    fn temp_path(ext: &str) -> PathBuf {
983        let id = uuid::Uuid::new_v4();
984        std::env::temp_dir().join(format!("test_{}.{}", id, ext))
985    }
986
987    /// Helper: create a SchLib with a simple 3-pin IC.
988    fn create_test_library() -> PathBuf {
989        let lib_path = temp_path("SchLib");
990        schlib_create(&lib_path).unwrap();
991        cmd_gen_ic(
992            &lib_path,
993            "LDO_3PIN",
994            "1:VIN:power:left,2:VOUT:power:right,3:GND:power:bottom",
995            Some("Test LDO".to_string()),
996            "600mil",
997            "200mil",
998            "100mil",
999        )
1000        .unwrap();
1001        cmd_gen_ic(
1002            &lib_path,
1003            "IC_4PIN",
1004            "1:VCC:power:top,2:IN:input:left,3:OUT:output:right,4:GND:power:bottom",
1005            Some("Test IC".to_string()),
1006            "400mil",
1007            "200mil",
1008            "100mil",
1009        )
1010        .unwrap();
1011        lib_path
1012    }
1013
1014    /// E2E: Create schematic, add component, verify designator survives save/reload.
1015    #[test]
1016    fn test_add_component_designator_roundtrip() {
1017        let lib_path = create_test_library();
1018        let sch_path = temp_path("SchDoc");
1019
1020        // Create empty SchDoc
1021        crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1022
1023        // Add component with explicit designator
1024        cmd_add_component(
1025            &sch_path,
1026            &lib_path,
1027            "LDO_3PIN",
1028            "1000",
1029            "2000",
1030            Some("U1"),
1031            0,
1032            None,
1033        )
1034        .unwrap();
1035
1036        // Reload and check designator
1037        let doc = crate::io::SchDoc::open_file(&sch_path).unwrap();
1038
1039        // Must have exactly 1 component
1040        let components: Vec<_> = doc
1041            .primitives
1042            .iter()
1043            .filter(|r| matches!(r, SchRecord::Component(_)))
1044            .collect();
1045        assert_eq!(components.len(), 1, "Expected 1 component");
1046
1047        // Must have exactly 1 Designator record (not Parameter)
1048        let designators: Vec<_> = doc
1049            .primitives
1050            .iter()
1051            .filter(|r| matches!(r, SchRecord::Designator(_)))
1052            .collect();
1053        assert_eq!(
1054            designators.len(),
1055            1,
1056            "Expected 1 Designator record, found {}. \
1057             Designator may be serializing as Parameter (RECORD=41 vs 34).",
1058            designators.len()
1059        );
1060
1061        if let SchRecord::Designator(d) = &designators[0] {
1062            assert_eq!(d.text(), "U1", "Designator text must be U1");
1063        }
1064
1065        std::fs::remove_file(&sch_path).ok();
1066        std::fs::remove_file(&lib_path).ok();
1067    }
1068
1069    /// E2E: Multiple components get distinct designators.
1070    #[test]
1071    fn test_multiple_components_distinct_designators() {
1072        let lib_path = create_test_library();
1073        let sch_path = temp_path("SchDoc");
1074
1075        crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1076
1077        cmd_add_component(
1078            &sch_path, &lib_path, "LDO_3PIN", "1000", "2000",
1079            Some("U1"), 0, None,
1080        ).unwrap();
1081        cmd_add_component(
1082            &sch_path, &lib_path, "IC_4PIN", "3000", "2000",
1083            Some("U2"), 0, None,
1084        ).unwrap();
1085        cmd_add_component(
1086            &sch_path, &lib_path, "LDO_3PIN", "5000", "2000",
1087            Some("U3"), 0, None,
1088        ).unwrap();
1089
1090        let doc = crate::io::SchDoc::open_file(&sch_path).unwrap();
1091
1092        let designators: Vec<String> = doc
1093            .primitives
1094            .iter()
1095            .filter_map(|r| {
1096                if let SchRecord::Designator(d) = r {
1097                    Some(d.text().to_string())
1098                } else {
1099                    None
1100                }
1101            })
1102            .collect();
1103
1104        assert_eq!(designators.len(), 3, "Expected 3 designators");
1105        assert!(designators.contains(&"U1".to_string()));
1106        assert!(designators.contains(&"U2".to_string()));
1107        assert!(designators.contains(&"U3".to_string()));
1108
1109        std::fs::remove_file(&sch_path).ok();
1110        std::fs::remove_file(&lib_path).ok();
1111    }
1112
1113    /// E2E: Components with top/bottom pins have correct pin count after placement.
1114    #[test]
1115    fn test_placed_component_preserves_all_pins() {
1116        let lib_path = create_test_library();
1117        let sch_path = temp_path("SchDoc");
1118
1119        crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1120
1121        // IC_4PIN has pins on all 4 sides
1122        cmd_add_component(
1123            &sch_path, &lib_path, "IC_4PIN", "2000", "2000",
1124            Some("U1"), 0, None,
1125        ).unwrap();
1126
1127        let doc = crate::io::SchDoc::open_file(&sch_path).unwrap();
1128
1129        // Count pins owned by the component (owner_index = component index)
1130        let comp_index = doc
1131            .primitives
1132            .iter()
1133            .position(|r| matches!(r, SchRecord::Component(_)))
1134            .unwrap();
1135
1136        let pin_count = doc
1137            .primitives
1138            .iter()
1139            .filter(|r| {
1140                if let SchRecord::Pin(p) = r {
1141                    p.graphical.base.owner_index == comp_index as i32
1142                } else {
1143                    false
1144                }
1145            })
1146            .count();
1147
1148        assert_eq!(
1149            pin_count, 4,
1150            "IC_4PIN has 4 pins (top/bottom/left/right), but only {} survived placement",
1151            pin_count
1152        );
1153
1154        std::fs::remove_file(&sch_path).ok();
1155        std::fs::remove_file(&lib_path).ok();
1156    }
1157
1158    /// E2E: Power ports and net labels survive save/reload.
1159    #[test]
1160    fn test_power_and_netlabel_roundtrip() {
1161        let sch_path = temp_path("SchDoc");
1162        crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1163
1164        // Add power ports
1165        cmd_add_power(
1166            &sch_path, "3V3", "1000", "2000", "bar", "up", None,
1167        ).unwrap();
1168        cmd_add_power(
1169            &sch_path, "GND", "1000", "1000", "ground", "down", None,
1170        ).unwrap();
1171
1172        // Add net label
1173        cmd_add_net_label(
1174            &sch_path, "SDA", "2000", "2000", None,
1175        ).unwrap();
1176
1177        let doc = crate::io::SchDoc::open_file(&sch_path).unwrap();
1178
1179        let power_count = doc
1180            .primitives
1181            .iter()
1182            .filter(|r| matches!(r, SchRecord::PowerObject(_)))
1183            .count();
1184        assert_eq!(power_count, 2, "Expected 2 power ports");
1185
1186        let net_labels: Vec<_> = doc
1187            .primitives
1188            .iter()
1189            .filter_map(|r| {
1190                if let SchRecord::NetLabel(nl) = r {
1191                    Some(nl.label.text.clone())
1192                } else {
1193                    None
1194                }
1195            })
1196            .collect();
1197        assert_eq!(net_labels.len(), 1);
1198        assert_eq!(net_labels[0], "SDA");
1199
1200        std::fs::remove_file(&sch_path).ok();
1201    }
1202
1203    /// E2E: Full workflow — library creation, component placement, designator lookup.
1204    #[test]
1205    fn test_full_schematic_capture_workflow() {
1206        let lib_path = create_test_library();
1207        let sch_path = temp_path("SchDoc");
1208
1209        // Step 1: Create schematic
1210        crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1211
1212        // Step 2: Place components
1213        cmd_add_component(
1214            &sch_path, &lib_path, "LDO_3PIN", "1000", "3000",
1215            Some("U1"), 0, None,
1216        ).unwrap();
1217        cmd_add_component(
1218            &sch_path, &lib_path, "IC_4PIN", "3000", "3000",
1219            Some("U2"), 0, None,
1220        ).unwrap();
1221
1222        // Step 3: Add power ports
1223        cmd_add_power(
1224            &sch_path, "3V3", "2000", "4000", "bar", "up", None,
1225        ).unwrap();
1226        cmd_add_power(
1227            &sch_path, "GND", "2000", "2000", "ground", "down", None,
1228        ).unwrap();
1229
1230        // Step 4: Add net labels
1231        cmd_add_net_label(
1232            &sch_path, "VIN_3V3", "500", "3000", None,
1233        ).unwrap();
1234
1235        // Step 5: Validate — reload and verify structure
1236        let doc = crate::io::SchDoc::open_file(&sch_path).unwrap();
1237
1238        let comp_count = doc.primitives.iter()
1239            .filter(|r| matches!(r, SchRecord::Component(_))).count();
1240        let des_count = doc.primitives.iter()
1241            .filter(|r| matches!(r, SchRecord::Designator(_))).count();
1242        let power_count = doc.primitives.iter()
1243            .filter(|r| matches!(r, SchRecord::PowerObject(_))).count();
1244        let net_count = doc.primitives.iter()
1245            .filter(|r| matches!(r, SchRecord::NetLabel(_))).count();
1246
1247        assert_eq!(comp_count, 2, "2 components");
1248        assert_eq!(des_count, 2, "2 designators (one per component)");
1249        assert_eq!(power_count, 2, "2 power ports");
1250        assert_eq!(net_count, 1, "1 net label");
1251
1252        // Verify no Designator records were misclassified as Parameter
1253        let misclassified = doc.primitives.iter()
1254            .filter(|r| {
1255                if let SchRecord::Parameter(p) = r {
1256                    p.name == "Designator"
1257                } else {
1258                    false
1259                }
1260            })
1261            .count();
1262        assert_eq!(misclassified, 0, "No designators should be misclassified as Parameter");
1263
1264        std::fs::remove_file(&sch_path).ok();
1265        std::fs::remove_file(&lib_path).ok();
1266    }
1267}