1use 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
18fn parse_unit_value_or_mil(s: &str) -> Result<f64, String> {
20 let s = s.trim();
21
22 if let Ok((coord, unit)) = Unit::parse_with_unit(s) {
24 if unit != Unit::DxpDefault {
26 return Ok(coord.to_mils());
27 }
28 }
29
30 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
39pub 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
57pub 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#[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
123pub 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
184pub 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 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
223pub 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 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
253pub 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 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
292fn parse_point_spec(spec: &str, session: &EditSession) -> Result<(CoordPoint, String), String> {
294 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 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 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 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 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
440pub 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
463pub 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
488pub 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
507pub 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
536pub 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
592pub 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 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
657pub 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 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
721pub 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
746pub 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
764pub 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
808pub 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
866pub 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
891pub 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
911pub 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
946pub 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 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 #[test]
1016 fn test_add_component_designator_roundtrip() {
1017 let lib_path = create_test_library();
1018 let sch_path = temp_path("SchDoc");
1019
1020 crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1022
1023 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 let doc = crate::io::SchDoc::open_file(&sch_path).unwrap();
1038
1039 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 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 #[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 #[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 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 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 #[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 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 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 #[test]
1205 fn test_full_schematic_capture_workflow() {
1206 let lib_path = create_test_library();
1207 let sch_path = temp_path("SchDoc");
1208
1209 crate::ops::schdoc::cmd_create(&sch_path, None).unwrap();
1211
1212 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 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 cmd_add_net_label(
1232 &sch_path, "VIN_3V3", "500", "3000", None,
1233 ).unwrap();
1234
1235 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 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}