Skip to main content

altium_format/ops/
prjpcb.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// SPDX-FileCopyrightText: 2026 Alexander Kiselev <alex@akiselev.com>
3//
4//! Project file operations.
5//!
6//! High-level operations for managing Altium project (.PrjPcb) files.
7
8use std::collections::{HashMap, HashSet};
9use std::fs::File;
10use std::io::BufReader;
11use std::path::{Path, PathBuf};
12
13use crate::io::{DocumentType, PcbDoc, PrjPcb, SchDoc};
14use crate::ops::output::*;
15use crate::records::sch::SchRecord;
16use crate::tree::RecordTree;
17
18fn open_prjpcb(path: &Path) -> Result<PrjPcb, Box<dyn std::error::Error>> {
19    Ok(PrjPcb::open_file(path)?)
20}
21
22/// Get the project directory.
23fn project_dir(path: &Path) -> PathBuf {
24    path.parent().unwrap_or(Path::new(".")).to_path_buf()
25}
26
27/// Resolve a document path relative to the project.
28fn resolve_document_path(project_path: &Path, doc_path: &str) -> PathBuf {
29    let project_dir = project_dir(project_path);
30    project_dir.join(doc_path)
31}
32
33/// Open a schematic document from the project.
34fn open_schdoc(project_path: &Path, doc_path: &str) -> Result<SchDoc, Box<dyn std::error::Error>> {
35    let full_path = resolve_document_path(project_path, doc_path);
36    let file = File::open(&full_path)?;
37    Ok(SchDoc::open(BufReader::new(file))?)
38}
39
40/// Open a PCB document from the project.
41fn open_pcbdoc(project_path: &Path, doc_path: &str) -> Result<PcbDoc, Box<dyn std::error::Error>> {
42    let full_path = resolve_document_path(project_path, doc_path);
43    let file = File::open(&full_path)?;
44    Ok(PcbDoc::open(BufReader::new(file))?)
45}
46
47// ═══════════════════════════════════════════════════════════════════════════
48// COMPONENT AND NET EXTRACTION UTILITIES
49// ═══════════════════════════════════════════════════════════════════════════
50
51/// Component info extracted from schematics.
52#[derive(Debug, Clone, Default)]
53struct SchematicComponent {
54    designator: String,
55    lib_reference: String,
56    description: String,
57    footprint: String,
58    value: String,
59    sheet: String,
60    parameters: HashMap<String, String>,
61}
62
63/// Net info extracted from schematics.
64#[derive(Debug, Clone, Default)]
65struct SchematicNet {
66    name: String,
67    pins: Vec<NetPin>,
68}
69
70/// Pin connection in a net.
71#[derive(Debug, Clone, Default)]
72struct NetPin {
73    component: String,
74    pin: String,
75}
76
77/// Extract all components from project schematics.
78fn extract_components(
79    prj: &PrjPcb,
80    project_path: &Path,
81) -> Result<Vec<SchematicComponent>, Box<dyn std::error::Error>> {
82    let mut components = Vec::new();
83
84    for doc in prj.schematics() {
85        let schdoc = match open_schdoc(project_path, &doc.path) {
86            Ok(s) => s,
87            Err(e) => {
88                log::warn!("Failed to open schematic document {}: {}", doc.path, e);
89                continue;
90            }
91        };
92
93        // Build a tree to find designators
94        let tree = RecordTree::from_records(schdoc.primitives.clone());
95
96        // Extract components from this schematic
97        for (id, record) in tree.iter() {
98            if let SchRecord::Component(comp) = record {
99                // Find designator from child records
100                let mut designator = String::new();
101                for (_child_id, child) in tree.children(id) {
102                    if let SchRecord::Designator(d) = child {
103                        designator = d.param.label.text.clone();
104                        break;
105                    }
106                }
107
108                let sch_comp = SchematicComponent {
109                    designator,
110                    lib_reference: comp.lib_reference.clone(),
111                    description: comp.component_description.clone(),
112                    sheet: doc.path.clone(),
113                    ..Default::default()
114                };
115
116                components.push(sch_comp);
117            }
118        }
119    }
120
121    Ok(components)
122}
123
124/// Extract all nets from project schematics.
125fn extract_nets(
126    prj: &PrjPcb,
127    project_path: &Path,
128) -> Result<Vec<SchematicNet>, Box<dyn std::error::Error>> {
129    let mut net_map: HashMap<String, SchematicNet> = HashMap::new();
130
131    for doc in prj.schematics() {
132        let schdoc = match open_schdoc(project_path, &doc.path) {
133            Ok(s) => s,
134            Err(e) => {
135                log::warn!("Failed to open schematic document {}: {}", doc.path, e);
136                continue;
137            }
138        };
139
140        // Extract net labels and power ports
141        for record in &schdoc.primitives {
142            match record {
143                SchRecord::NetLabel(label) => {
144                    let net_name = label.label.text.clone();
145                    let net = net_map
146                        .entry(net_name.clone())
147                        .or_insert_with(|| SchematicNet {
148                            name: net_name,
149                            pins: Vec::new(),
150                        });
151                    // Net labels mark connection points but don't directly connect to pins
152                    let _ = net; // Mark as used
153                }
154                SchRecord::PowerObject(power) => {
155                    let net_name = power.text.clone();
156                    let net = net_map
157                        .entry(net_name.clone())
158                        .or_insert_with(|| SchematicNet {
159                            name: net_name,
160                            pins: Vec::new(),
161                        });
162                    let _ = net;
163                }
164                _ => {}
165            }
166        }
167    }
168
169    Ok(net_map.into_values().collect())
170}
171
172// ═══════════════════════════════════════════════════════════════════════════
173// HIGH-LEVEL COMMANDS
174// ═══════════════════════════════════════════════════════════════════════════
175
176pub fn cmd_overview(path: &Path) -> Result<PrjPcbOverview, Box<dyn std::error::Error>> {
177    let prj = open_prjpcb(path)?;
178
179    let schematics: Vec<_> = prj
180        .documents
181        .iter()
182        .filter(|d| d.doc_type == DocumentType::Schematic)
183        .map(|d| DocumentInfo {
184            path: d.path.clone(),
185            doc_type: d.doc_type.display_name().to_string(),
186            exists: resolve_document_path(path, &d.path).exists(),
187        })
188        .collect();
189
190    let pcb_documents: Vec<_> = prj
191        .documents
192        .iter()
193        .filter(|d| d.doc_type == DocumentType::Pcb)
194        .map(|d| DocumentInfo {
195            path: d.path.clone(),
196            doc_type: d.doc_type.display_name().to_string(),
197            exists: resolve_document_path(path, &d.path).exists(),
198        })
199        .collect();
200
201    let libraries: Vec<_> = prj
202        .documents
203        .iter()
204        .filter(|d| {
205            d.doc_type == DocumentType::SchLib
206                || d.doc_type == DocumentType::PcbLib
207                || d.doc_type == DocumentType::IntLib
208        })
209        .map(|d| DocumentInfo {
210            path: d.path.clone(),
211            doc_type: d.doc_type.display_name().to_string(),
212            exists: resolve_document_path(path, &d.path).exists(),
213        })
214        .collect();
215
216    let other: Vec<_> = prj
217        .documents
218        .iter()
219        .filter(|d| d.doc_type == DocumentType::Other || d.doc_type == DocumentType::OutputJob)
220        .map(|d| DocumentInfo {
221            path: d.path.clone(),
222            doc_type: d.doc_type.display_name().to_string(),
223            exists: resolve_document_path(path, &d.path).exists(),
224        })
225        .collect();
226
227    let document_summary = DocumentSummary {
228        total_documents: prj.documents.len(),
229        schematics,
230        pcb_documents,
231        libraries,
232        other,
233    };
234
235    // Component summary (if schematics exist)
236    let component_summary = if !prj.schematics().is_empty() {
237        extract_components(&prj, path).ok().and_then(|components| {
238            if components.is_empty() {
239                None
240            } else {
241                // Count by prefix
242                let mut by_prefix: HashMap<String, usize> = HashMap::new();
243                for comp in &components {
244                    let prefix: String = comp
245                        .designator
246                        .chars()
247                        .take_while(|c| c.is_alphabetic())
248                        .collect();
249                    *by_prefix.entry(prefix).or_default() += 1;
250                }
251
252                let mut prefixes: Vec<_> = by_prefix
253                    .into_iter()
254                    .map(|(prefix, count)| {
255                        let display_name = match prefix.as_str() {
256                            "R" => "Resistors".to_string(),
257                            "C" => "Capacitors".to_string(),
258                            "L" => "Inductors".to_string(),
259                            "U" => "ICs".to_string(),
260                            "Q" => "Transistors".to_string(),
261                            "D" => "Diodes".to_string(),
262                            "J" | "P" => "Connectors".to_string(),
263                            "SW" | "S" => "Switches".to_string(),
264                            "F" => "Fuses".to_string(),
265                            "Y" => "Crystals".to_string(),
266                            _ => prefix.clone(),
267                        };
268                        (prefix, display_name, count)
269                    })
270                    .collect();
271                prefixes.sort_by(|a, b| b.2.cmp(&a.2));
272
273                Some(ComponentSummaryStats {
274                    total_components: components.len(),
275                    by_prefix: prefixes,
276                })
277            }
278        })
279    } else {
280        None
281    };
282
283    Ok(PrjPcbOverview {
284        path: path.display().to_string(),
285        name: prj.name(),
286        version: prj.version.clone(),
287        hierarchy_mode: if prj.hierarchy_mode == 0 {
288            "Flat".to_string()
289        } else {
290            "Hierarchical".to_string()
291        },
292        document_summary,
293        parameters: prj.parameters.clone(),
294        component_summary,
295    })
296}
297
298pub fn cmd_info(path: &Path) -> Result<PrjPcbInfo, Box<dyn std::error::Error>> {
299    let prj = open_prjpcb(path)?;
300
301    let mut by_type: HashMap<&str, usize> = HashMap::new();
302    for doc in &prj.documents {
303        *by_type.entry(doc.doc_type.display_name()).or_default() += 1;
304    }
305
306    let mut document_counts: Vec<_> = by_type
307        .into_iter()
308        .map(|(k, v)| (k.to_string(), v))
309        .collect();
310    document_counts.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
311
312    Ok(PrjPcbInfo {
313        path: path.display().to_string(),
314        name: prj.name(),
315        version: prj.version.clone(),
316        hierarchy_mode: if prj.hierarchy_mode == 0 {
317            "Flat".to_string()
318        } else {
319            "Hierarchical".to_string()
320        },
321        output_path: if prj.output_path.is_empty() {
322            "(default)".to_string()
323        } else {
324            prj.output_path.clone()
325        },
326        annotation_start: prj.annotation_start_value,
327        document_counts,
328        parameter_count: prj.parameters.len(),
329        erc_matrix_rows: prj.erc_matrix.rows.len(),
330    })
331}
332
333pub fn cmd_documents(
334    path: &Path,
335    doc_type: Option<String>,
336) -> Result<PrjPcbDocumentList, Box<dyn std::error::Error>> {
337    let prj = open_prjpcb(path)?;
338
339    let filter_type = doc_type.as_ref().map(|t| t.to_lowercase());
340
341    let documents: Vec<_> = prj
342        .documents
343        .iter()
344        .filter(|d| {
345            if let Some(ref filter) = filter_type {
346                d.doc_type.display_name().to_lowercase().contains(filter)
347            } else {
348                true
349            }
350        })
351        .map(|d| DocumentDetailInfo {
352            path: d.path.clone(),
353            doc_type: d.doc_type.display_name().to_string(),
354            exists: resolve_document_path(path, &d.path).exists(),
355            annotation_enabled: d.annotation_enabled,
356            library_update: d.do_library_update,
357        })
358        .collect();
359
360    Ok(PrjPcbDocumentList {
361        path: path.display().to_string(),
362        filter: doc_type,
363        total_documents: documents.len(),
364        documents,
365    })
366}
367
368// ═══════════════════════════════════════════════════════════════════════════
369// CREATION COMMANDS
370// ═══════════════════════════════════════════════════════════════════════════
371
372/// Embedded blank PrjPcb template.
373const BLANK_PRJPCB_TEMPLATE: &[u8] = include_bytes!("../../data/Project1.PrjPcb");
374
375pub fn cmd_create(
376    path: &Path,
377    name: Option<String>,
378    template: Option<PathBuf>,
379) -> Result<String, Box<dyn std::error::Error>> {
380    if path.exists() {
381        return Err(format!("File already exists: {}", path.display()).into());
382    }
383
384    let message = match template {
385        Some(template_path) => {
386            std::fs::copy(&template_path, path)?;
387            format!(
388                "Created project from template: {}\n  Template: {}",
389                path.display(),
390                template_path.display()
391            )
392        }
393        None => {
394            // Use embedded template
395            std::fs::write(path, BLANK_PRJPCB_TEMPLATE)?;
396
397            // If name specified, update it
398            if let Some(ref project_name) = name {
399                let mut prj = open_prjpcb(path)?;
400                prj.set_name(project_name);
401                prj.save_to_file(path)?;
402            }
403
404            format!("Created new project: {}", path.display())
405        }
406    };
407
408    // Verify
409    let prj = open_prjpcb(path)?;
410    Ok(format!(
411        "{}\n  Name: {}\n  Documents: {}",
412        message,
413        prj.name(),
414        prj.documents.len()
415    ))
416}
417
418// ═══════════════════════════════════════════════════════════════════════════
419// DOCUMENT MANAGEMENT
420// ═══════════════════════════════════════════════════════════════════════════
421
422pub fn cmd_add_document(path: &Path, document: &str) -> Result<String, Box<dyn std::error::Error>> {
423    let mut prj = open_prjpcb(path)?;
424
425    // Check if document already exists
426    if prj.get_document(document).is_some() {
427        return Err(format!("Document '{}' already in project", document).into());
428    }
429
430    prj.add_document(document);
431    prj.save_to_file(path)?;
432
433    let doc_type = DocumentType::from_path(document);
434    Ok(format!(
435        "Added {} to project: {}\n  Type: {}\n  Total documents: {}",
436        document,
437        path.display(),
438        doc_type,
439        prj.documents.len()
440    ))
441}
442
443pub fn cmd_remove_document(
444    path: &Path,
445    document: &str,
446) -> Result<String, Box<dyn std::error::Error>> {
447    let mut prj = open_prjpcb(path)?;
448
449    if !prj.remove_document(document) {
450        return Err(format!("Document '{}' not found in project", document).into());
451    }
452
453    prj.save_to_file(path)?;
454
455    Ok(format!(
456        "Removed {} from project: {}\n  Remaining documents: {}",
457        document,
458        path.display(),
459        prj.documents.len()
460    ))
461}
462
463// ═══════════════════════════════════════════════════════════════════════════
464// PARAMETER MANAGEMENT
465// ═══════════════════════════════════════════════════════════════════════════
466
467pub fn cmd_parameters(path: &Path) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
468    let prj = open_prjpcb(path)?;
469    Ok(prj.parameters.clone())
470}
471
472pub fn cmd_set_parameter(
473    path: &Path,
474    name: &str,
475    value: &str,
476) -> Result<String, Box<dyn std::error::Error>> {
477    let mut prj = open_prjpcb(path)?;
478
479    let was_existing = prj.parameters.contains_key(name);
480    prj.set_parameter(name, value);
481    prj.save_to_file(path)?;
482
483    if was_existing {
484        Ok(format!(
485            "Updated parameter '{}' = '{}' in {}",
486            name,
487            value,
488            path.display()
489        ))
490    } else {
491        Ok(format!(
492            "Added parameter '{}' = '{}' to {}",
493            name,
494            value,
495            path.display()
496        ))
497    }
498}
499
500pub fn cmd_remove_parameter(path: &Path, name: &str) -> Result<String, Box<dyn std::error::Error>> {
501    let mut prj = open_prjpcb(path)?;
502
503    if prj.remove_parameter(name).is_none() {
504        return Err(format!("Parameter '{}' not found", name).into());
505    }
506
507    prj.save_to_file(path)?;
508    Ok(format!(
509        "Removed parameter '{}' from {}",
510        name,
511        path.display()
512    ))
513}
514
515// ═══════════════════════════════════════════════════════════════════════════
516// NETLIST AND COMPONENT EXTRACTION
517// ═══════════════════════════════════════════════════════════════════════════
518
519pub fn cmd_netlist(path: &Path) -> Result<PrjPcbNetlist, Box<dyn std::error::Error>> {
520    let prj = open_prjpcb(path)?;
521    let nets = extract_nets(&prj, path)?;
522
523    let net_infos: Vec<NetInfo> = nets
524        .into_iter()
525        .map(|net| NetInfo {
526            name: net.name,
527            pins: net
528                .pins
529                .into_iter()
530                .map(|pin| NetPinConnection {
531                    component: pin.component,
532                    pin: pin.pin,
533                })
534                .collect(),
535        })
536        .collect();
537
538    Ok(PrjPcbNetlist {
539        path: path.display().to_string(),
540        total_nets: net_infos.len(),
541        nets: net_infos,
542    })
543}
544
545pub fn cmd_components(path: &Path) -> Result<PrjPcbComponentList, Box<dyn std::error::Error>> {
546    let prj = open_prjpcb(path)?;
547    let components = extract_components(&prj, path)?;
548
549    let component_infos: Vec<SchematicComponentInfo> = components
550        .into_iter()
551        .map(|comp| SchematicComponentInfo {
552            designator: comp.designator,
553            lib_reference: comp.lib_reference,
554            description: comp.description,
555            footprint: comp.footprint,
556            value: comp.value,
557            sheet: comp.sheet,
558            parameters: comp.parameters,
559        })
560        .collect();
561
562    Ok(PrjPcbComponentList {
563        path: path.display().to_string(),
564        total_components: component_infos.len(),
565        components: component_infos,
566    })
567}
568
569pub fn cmd_bom(path: &Path, grouped: bool) -> Result<PrjPcbBom, Box<dyn std::error::Error>> {
570    let prj = open_prjpcb(path)?;
571    let components = extract_components(&prj, path)?;
572
573    let items = if grouped {
574        // Group by lib_reference
575        let mut groups: HashMap<String, Vec<&SchematicComponent>> = HashMap::new();
576        for comp in &components {
577            groups
578                .entry(comp.lib_reference.clone())
579                .or_default()
580                .push(comp);
581        }
582
583        let mut group_items: Vec<_> = groups
584            .into_iter()
585            .map(|(lib_ref, comps)| BomGroupItem {
586                lib_reference: lib_ref,
587                quantity: comps.len(),
588                designators: comps.iter().map(|c| c.designator.clone()).collect(),
589            })
590            .collect();
591        group_items.sort_by(|a, b| b.quantity.cmp(&a.quantity));
592
593        BomItems::Grouped(group_items)
594    } else {
595        let component_infos: Vec<SchematicComponentInfo> = components
596            .into_iter()
597            .map(|comp| SchematicComponentInfo {
598                designator: comp.designator,
599                lib_reference: comp.lib_reference,
600                description: comp.description,
601                footprint: comp.footprint,
602                value: comp.value,
603                sheet: comp.sheet,
604                parameters: comp.parameters,
605            })
606            .collect();
607
608        BomItems::Individual(component_infos)
609    };
610
611    let (total_components, unique_parts) = match &items {
612        BomItems::Grouped(groups) => {
613            let total = groups.iter().map(|g| g.quantity).sum();
614            (total, Some(groups.len()))
615        }
616        BomItems::Individual(comps) => (comps.len(), None),
617    };
618
619    Ok(PrjPcbBom {
620        path: path.display().to_string(),
621        total_components,
622        unique_parts,
623        items,
624    })
625}
626
627// ═══════════════════════════════════════════════════════════════════════════
628// PCB IMPORT COMMANDS
629// ═══════════════════════════════════════════════════════════════════════════
630
631pub fn cmd_import_to_pcb(
632    path: &Path,
633    pcb: Option<String>,
634    dry_run: bool,
635) -> Result<String, Box<dyn std::error::Error>> {
636    let prj = open_prjpcb(path)?;
637
638    // Find the target PCB
639    let pcb_doc = if let Some(ref pcb_path) = pcb {
640        prj.get_document(pcb_path)
641            .ok_or_else(|| format!("PCB document '{}' not found in project", pcb_path))?
642    } else {
643        prj.primary_pcb()
644            .ok_or_else(|| "No PCB document found in project".to_string())?
645    };
646
647    let pcb_path_str = pcb_doc.path.clone();
648
649    // Extract components and nets from schematics
650    let components = extract_components(&prj, path)?;
651    let nets = extract_nets(&prj, path)?;
652
653    if dry_run {
654        let mut message = format!("Import to PCB: {}\n", path.display());
655        message.push_str(&format!("Target PCB: {}\n", pcb_path_str));
656        message.push_str(&format!("Components to import: {}\n", components.len()));
657        message.push_str(&format!("Nets to import: {}\n\n", nets.len()));
658        message.push_str("[DRY RUN - No changes will be made]\n\n");
659        message.push_str("Components that would be added:\n");
660        for comp in components.iter().take(20) {
661            message.push_str(&format!("  {} - {}\n", comp.designator, comp.lib_reference));
662        }
663        if components.len() > 20 {
664            message.push_str(&format!("  ... and {} more\n", components.len() - 20));
665        }
666        message.push_str("\nNets that would be added:\n");
667        for net in nets.iter().take(20) {
668            message.push_str(&format!("  {}\n", net.name));
669        }
670        if nets.len() > 20 {
671            message.push_str(&format!("  ... and {} more", nets.len() - 20));
672        }
673        Ok(message)
674    } else {
675        // Open the PCB and add components
676        let full_pcb_path = resolve_document_path(path, &pcb_path_str);
677        let mut pcbdoc = open_pcbdoc(path, &pcb_path_str)?;
678
679        // Get existing components and nets
680        let existing_designators: HashSet<_> = pcbdoc
681            .components
682            .iter()
683            .map(|c| c.designator.clone())
684            .collect();
685        let existing_nets: HashSet<_> = pcbdoc.nets.iter().cloned().collect();
686
687        let mut added_components = 0;
688        let mut added_nets = 0;
689
690        // Add missing components
691        for comp in &components {
692            if !existing_designators.contains(&comp.designator) {
693                // Would add component here - for now just count
694                added_components += 1;
695            }
696        }
697
698        // Add missing nets
699        for net in &nets {
700            if !existing_nets.contains(&net.name) {
701                pcbdoc.nets.push(net.name.clone());
702                added_nets += 1;
703            }
704        }
705
706        // Save if changes were made
707        if added_components > 0 || added_nets > 0 {
708            pcbdoc.save_to_file(&full_pcb_path)?;
709            Ok(format!(
710                "Import complete:\n  Added {} nets\n  Components: {} (component placement not yet implemented)",
711                added_nets, added_components
712            ))
713        } else {
714            Ok("No changes needed - PCB already up to date".to_string())
715        }
716    }
717}
718
719pub fn cmd_sync_to_pcb(
720    path: &Path,
721    pcb: Option<String>,
722    dry_run: bool,
723) -> Result<String, Box<dyn std::error::Error>> {
724    // For now, this is similar to import but focuses on synchronization
725    cmd_import_to_pcb(path, pcb, dry_run)
726}
727
728pub fn cmd_diff_sch_pcb(
729    path: &Path,
730    pcb: Option<String>,
731) -> Result<PrjPcbSchPcbDiff, Box<dyn std::error::Error>> {
732    let prj = open_prjpcb(path)?;
733
734    // Find the target PCB
735    let pcb_doc = if let Some(ref pcb_path) = pcb {
736        prj.get_document(pcb_path)
737            .ok_or_else(|| format!("PCB document '{}' not found in project", pcb_path))?
738    } else {
739        prj.primary_pcb()
740            .ok_or_else(|| "No PCB document found in project".to_string())?
741    };
742
743    let pcb_path_str = pcb_doc.path.clone();
744
745    // Extract from schematics
746    let sch_components = extract_components(&prj, path)?;
747    let sch_nets = extract_nets(&prj, path)?;
748
749    // Load PCB
750    let pcbdoc = open_pcbdoc(path, &pcb_path_str)?;
751
752    let sch_designators: HashSet<_> = sch_components.iter().map(|c| &c.designator).collect();
753    let pcb_designators: HashSet<_> = pcbdoc.components.iter().map(|c| &c.designator).collect();
754
755    let sch_net_names: HashSet<_> = sch_nets.iter().map(|n| &n.name).collect();
756    let pcb_net_names: HashSet<_> = pcbdoc.nets.iter().collect();
757
758    // Components only in schematic
759    let only_in_schematic: Vec<String> = sch_designators
760        .difference(&pcb_designators)
761        .map(|s| s.to_string())
762        .collect();
763    // Components only in PCB
764    let only_in_pcb: Vec<String> = pcb_designators
765        .difference(&sch_designators)
766        .map(|s| s.to_string())
767        .collect();
768
769    // Nets only in schematic
770    let nets_only_in_schematic: Vec<String> = sch_net_names
771        .difference(&pcb_net_names)
772        .map(|s| s.to_string())
773        .collect();
774    // Nets only in PCB
775    let nets_only_in_pcb: Vec<String> = pcb_net_names
776        .difference(&sch_net_names)
777        .map(|s| s.to_string())
778        .collect();
779
780    Ok(PrjPcbSchPcbDiff {
781        path: path.display().to_string(),
782        pcb_document: pcb_path_str,
783        schematic_components: sch_components.len(),
784        pcb_components: pcbdoc.components.len(),
785        only_in_schematic,
786        only_in_pcb,
787        schematic_nets: sch_nets.len(),
788        pcb_nets: pcbdoc.nets.len(),
789        nets_only_in_schematic,
790        nets_only_in_pcb,
791    })
792}
793
794// ═══════════════════════════════════════════════════════════════════════════
795// VALIDATION
796// ═══════════════════════════════════════════════════════════════════════════
797
798pub fn cmd_validate(
799    path: &Path,
800    check_files: bool,
801) -> Result<PrjPcbValidation, Box<dyn std::error::Error>> {
802    let prj = open_prjpcb(path)?;
803
804    let mut errors = Vec::new();
805    let mut warnings = Vec::new();
806
807    // Basic validation
808    if prj.name().is_empty() || prj.name() == "Unnamed" {
809        warnings.push("Project has no name defined".to_string());
810    }
811
812    if prj.documents.is_empty() {
813        warnings.push("Project has no documents".to_string());
814    }
815
816    // Check for at least one schematic and one PCB
817    if prj.schematics().is_empty() {
818        warnings.push("Project has no schematic documents".to_string());
819    }
820    if prj.pcb_documents().is_empty() {
821        warnings.push("Project has no PCB documents".to_string());
822    }
823
824    // Check for duplicate document paths
825    let mut seen_paths = HashSet::new();
826    for doc in &prj.documents {
827        if !seen_paths.insert(&doc.path) {
828            errors.push(format!("Duplicate document path: {}", doc.path));
829        }
830    }
831
832    // Check file existence if requested
833    if check_files {
834        for doc in &prj.documents {
835            let full_path = resolve_document_path(path, &doc.path);
836            if !full_path.exists() {
837                errors.push(format!("Missing document: {}", doc.path));
838            }
839        }
840    }
841
842    Ok(PrjPcbValidation {
843        path: path.display().to_string(),
844        errors,
845        warnings,
846    })
847}
848
849// ═══════════════════════════════════════════════════════════════════════════
850// EXPORT
851// ═══════════════════════════════════════════════════════════════════════════
852
853pub fn cmd_json(
854    path: &Path,
855    full: bool,
856    pretty: bool,
857) -> Result<String, Box<dyn std::error::Error>> {
858    use serde::Serialize;
859
860    let prj = open_prjpcb(path)?;
861
862    #[derive(Serialize)]
863    struct DocumentJson {
864        path: String,
865        doc_type: String,
866        annotation_enabled: bool,
867    }
868
869    #[derive(Serialize)]
870    struct ProjectJson {
871        name: String,
872        version: String,
873        hierarchy_mode: i32,
874        output_path: String,
875        documents: Vec<DocumentJson>,
876        #[serde(skip_serializing_if = "HashMap::is_empty")]
877        parameters: HashMap<String, String>,
878    }
879
880    let documents: Vec<_> = prj
881        .documents
882        .iter()
883        .map(|d| DocumentJson {
884            path: d.path.clone(),
885            doc_type: d.doc_type.display_name().to_string(),
886            annotation_enabled: d.annotation_enabled,
887        })
888        .collect();
889
890    let output = ProjectJson {
891        name: prj.name(),
892        version: prj.version.clone(),
893        hierarchy_mode: prj.hierarchy_mode,
894        output_path: prj.output_path.clone(),
895        documents,
896        parameters: if full {
897            prj.parameters.clone()
898        } else {
899            HashMap::new()
900        },
901    };
902
903    let json = if pretty {
904        serde_json::to_string_pretty(&output)?
905    } else {
906        serde_json::to_string(&output)?
907    };
908
909    Ok(json)
910}