Skip to main content

altium_format/io/
prjpcb.rs

1//! PrjPcb reader/writer for Altium project files.
2//!
3//! Altium project files (.PrjPcb) are INI-style text files that define:
4//! - Project metadata and settings
5//! - List of documents (schematics, PCB, libraries)
6//! - ERC connection matrix settings
7//! - Output configurations
8//! - Project parameters
9
10use std::collections::HashMap;
11use std::fs::File;
12use std::io::{BufRead, BufReader, Read, Write};
13use std::path::{Path, PathBuf};
14
15use crate::dump::{DumpTree, TreeBuilder};
16use crate::error::{AltiumError, Result};
17
18/// A project document reference.
19#[derive(Debug, Clone, Default)]
20pub struct ProjectDocument {
21    /// Path to the document (relative to project)
22    pub path: String,
23    /// Document type inferred from extension
24    pub doc_type: DocumentType,
25    /// Whether annotation is enabled
26    pub annotation_enabled: bool,
27    /// Annotation start value
28    pub annotation_start_value: i32,
29    /// Whether to do library update
30    pub do_library_update: bool,
31    /// Whether to do database update
32    pub do_database_update: bool,
33    /// All parameters for this document
34    pub params: HashMap<String, String>,
35}
36
37/// Document type based on file extension.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub enum DocumentType {
40    /// Schematic document (.SchDoc)
41    Schematic,
42    /// PCB document (.PcbDoc)
43    Pcb,
44    /// Schematic library (.SchLib)
45    SchLib,
46    /// PCB library (.PcbLib)
47    PcbLib,
48    /// Integrated library (.IntLib)
49    IntLib,
50    /// Output job (.OutJob)
51    OutputJob,
52    /// Other/unknown type
53    #[default]
54    Other,
55}
56
57impl DocumentType {
58    /// Infer document type from file path.
59    pub fn from_path(path: &str) -> Self {
60        let lower = path.to_lowercase();
61        if lower.ends_with(".schdoc") {
62            DocumentType::Schematic
63        } else if lower.ends_with(".pcbdoc") {
64            DocumentType::Pcb
65        } else if lower.ends_with(".schlib") {
66            DocumentType::SchLib
67        } else if lower.ends_with(".pcblib") {
68            DocumentType::PcbLib
69        } else if lower.ends_with(".intlib") {
70            DocumentType::IntLib
71        } else if lower.ends_with(".outjob") {
72            DocumentType::OutputJob
73        } else {
74            DocumentType::Other
75        }
76    }
77
78    /// Get the display name for this document type.
79    pub fn display_name(&self) -> &'static str {
80        match self {
81            DocumentType::Schematic => "Schematic",
82            DocumentType::Pcb => "PCB",
83            DocumentType::SchLib => "Schematic Library",
84            DocumentType::PcbLib => "PCB Library",
85            DocumentType::IntLib => "Integrated Library",
86            DocumentType::OutputJob => "Output Job",
87            DocumentType::Other => "Other",
88        }
89    }
90}
91
92impl std::fmt::Display for DocumentType {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(f, "{}", self.display_name())
95    }
96}
97
98/// A project parameter.
99#[derive(Debug, Clone, Default)]
100pub struct ProjectParameter {
101    /// Parameter name
102    pub name: String,
103    /// Parameter value
104    pub value: String,
105}
106
107/// ERC (Electrical Rule Check) connection matrix.
108#[derive(Debug, Clone, Default)]
109pub struct ErcMatrix {
110    /// Matrix rows (L1-L17), each containing violation levels
111    pub rows: Vec<String>,
112}
113
114impl ErcMatrix {
115    /// Get the violation level between two pin types.
116    pub fn get_level(&self, row: usize, col: usize) -> Option<char> {
117        if row < self.rows.len() {
118            self.rows[row].chars().nth(col)
119        } else {
120            None
121        }
122    }
123
124    /// Decode ERC level character to description.
125    pub fn decode_level(c: char) -> &'static str {
126        match c {
127            'N' => "No Report",
128            'W' => "Warning",
129            'E' => "Error",
130            'A' => "ActiveLow Warning",
131            'B' => "Bidirectional",
132            'O' => "Open",
133            'R' => "Report",
134            _ => "Unknown",
135        }
136    }
137}
138
139/// Project variant for multi-variant designs.
140#[derive(Debug, Clone, Default)]
141pub struct ProjectVariant {
142    /// Variant name
143    pub name: String,
144    /// Variant description
145    pub description: String,
146    /// Parameter overrides for this variant
147    pub parameter_overrides: HashMap<String, String>,
148}
149
150/// Output group configuration.
151#[derive(Debug, Clone, Default)]
152pub struct OutputGroup {
153    /// Output group name
154    pub name: String,
155    /// Output type
156    pub output_type: String,
157    /// Settings
158    pub settings: HashMap<String, String>,
159}
160
161/// An Altium project file.
162#[derive(Debug, Clone, Default)]
163pub struct PrjPcb {
164    /// Project file path (if loaded from file)
165    pub path: Option<PathBuf>,
166    /// Project version
167    pub version: String,
168    /// Hierarchy mode (0 = flat, 1 = hierarchical)
169    pub hierarchy_mode: i32,
170    /// Output path
171    pub output_path: String,
172    /// Annotation start value
173    pub annotation_start_value: i32,
174    /// Documents in the project
175    pub documents: Vec<ProjectDocument>,
176    /// Project parameters
177    pub parameters: HashMap<String, String>,
178    /// ERC connection matrix
179    pub erc_matrix: ErcMatrix,
180    /// Project variants
181    pub variants: Vec<ProjectVariant>,
182    /// Output groups
183    pub output_groups: Vec<OutputGroup>,
184    /// All raw sections (for round-trip preservation)
185    pub sections: HashMap<String, HashMap<String, String>>,
186}
187
188impl PrjPcb {
189    /// Create a new empty project.
190    pub fn new() -> Self {
191        PrjPcb {
192            version: "1.0".to_string(),
193            ..Default::default()
194        }
195    }
196
197    /// Open and read a PrjPcb file.
198    pub fn open<R: Read>(reader: R) -> Result<Self> {
199        let buf_reader = BufReader::new(reader);
200        let mut prj = PrjPcb::default();
201
202        let mut current_section = String::new();
203        let mut current_section_data: HashMap<String, String> = HashMap::new();
204
205        for line_result in buf_reader.lines() {
206            let line = line_result.map_err(AltiumError::Io)?;
207            let trimmed = line.trim();
208
209            // Skip empty lines
210            if trimmed.is_empty() {
211                continue;
212            }
213
214            // Check for section header
215            if trimmed.starts_with('[') && trimmed.ends_with(']') {
216                // Save previous section
217                if !current_section.is_empty() {
218                    prj.process_section(&current_section, &current_section_data);
219                    prj.sections
220                        .insert(current_section.clone(), current_section_data.clone());
221                }
222
223                // Start new section
224                current_section = trimmed[1..trimmed.len() - 1].to_string();
225                current_section_data = HashMap::new();
226            } else if let Some(eq_pos) = trimmed.find('=') {
227                // Key=Value pair
228                let key = trimmed[..eq_pos].to_string();
229                let value = trimmed[eq_pos + 1..].to_string();
230                current_section_data.insert(key, value);
231            }
232        }
233
234        // Process last section
235        if !current_section.is_empty() {
236            prj.process_section(&current_section, &current_section_data);
237            prj.sections.insert(current_section, current_section_data);
238        }
239
240        Ok(prj)
241    }
242
243    /// Open and read a PrjPcb file from a path.
244    pub fn open_file<P: AsRef<Path>>(path: P) -> Result<Self> {
245        let path_ref = path.as_ref();
246        let file = File::open(path_ref)?;
247        let mut prj = Self::open(file)?;
248        prj.path = Some(path_ref.to_path_buf());
249        Ok(prj)
250    }
251
252    /// Process a section and populate appropriate fields.
253    fn process_section(&mut self, section: &str, data: &HashMap<String, String>) {
254        match section {
255            "Design" => self.process_design_section(data),
256            "Parameters" => {
257                self.parameters = data.clone();
258            }
259            "ERC Connection Matrix" => self.process_erc_section(data),
260            _ => {
261                // Check for DocumentN sections
262                if let Some(suffix) = section.strip_prefix("Document") {
263                    if suffix.parse::<i32>().is_ok() {
264                        self.process_document_section(data);
265                    }
266                }
267                // Check for OutputGroupN sections
268                if let Some(suffix) = section.strip_prefix("OutputGroup") {
269                    if suffix.parse::<i32>().is_ok() {
270                        self.process_output_group_section(section, data);
271                    }
272                }
273            }
274        }
275    }
276
277    /// Process the [Design] section.
278    fn process_design_section(&mut self, data: &HashMap<String, String>) {
279        if let Some(v) = data.get("Version") {
280            self.version = v.clone();
281        }
282        if let Some(v) = data.get("HierarchyMode") {
283            self.hierarchy_mode = v.parse().unwrap_or(0);
284        }
285        if let Some(v) = data.get("OutputPath") {
286            self.output_path = v.clone();
287        }
288        if let Some(v) = data.get("AnnotationStartValue") {
289            self.annotation_start_value = v.parse().unwrap_or(1);
290        }
291    }
292
293    /// Process a [DocumentN] section.
294    fn process_document_section(&mut self, data: &HashMap<String, String>) {
295        let path = data.get("DocumentPath").cloned().unwrap_or_default();
296        if path.is_empty() {
297            return;
298        }
299
300        let doc = ProjectDocument {
301            doc_type: DocumentType::from_path(&path),
302            path,
303            annotation_enabled: data
304                .get("AnnotationEnabled")
305                .map(|v| v == "1")
306                .unwrap_or(true),
307            annotation_start_value: data
308                .get("AnnotateStartValue")
309                .and_then(|v| v.parse().ok())
310                .unwrap_or(1),
311            do_library_update: data
312                .get("DoLibraryUpdate")
313                .map(|v| v == "1")
314                .unwrap_or(true),
315            do_database_update: data
316                .get("DoDatabaseUpdate")
317                .map(|v| v == "1")
318                .unwrap_or(true),
319            params: data.clone(),
320        };
321
322        self.documents.push(doc);
323    }
324
325    /// Process the [ERC Connection Matrix] section.
326    fn process_erc_section(&mut self, data: &HashMap<String, String>) {
327        let mut rows = Vec::new();
328        for i in 1..=17 {
329            let key = format!("L{}", i);
330            if let Some(v) = data.get(&key) {
331                rows.push(v.clone());
332            }
333        }
334        self.erc_matrix.rows = rows;
335    }
336
337    /// Process an [OutputGroupN] section.
338    fn process_output_group_section(&mut self, section: &str, data: &HashMap<String, String>) {
339        let group = OutputGroup {
340            name: data
341                .get("Name")
342                .cloned()
343                .unwrap_or_else(|| section.to_string()),
344            output_type: data.get("OutputType").cloned().unwrap_or_default(),
345            settings: data.clone(),
346        };
347        self.output_groups.push(group);
348    }
349
350    /// Save the project to a writer.
351    pub fn save<W: Write>(&self, mut writer: W) -> Result<()> {
352        // [Design] section
353        writeln!(writer, "[Design]")?;
354        writeln!(writer, "Version={}", self.version)?;
355        writeln!(writer, "HierarchyMode={}", self.hierarchy_mode)?;
356        writeln!(writer, "ChannelRoomNamingStyle=0")?;
357        writeln!(writer, "OutputPath={}", self.output_path)?;
358        writeln!(writer, "LogFolderPath=")?;
359        writeln!(
360            writer,
361            "AnnotationStartValue={}",
362            self.annotation_start_value
363        )?;
364        writeln!(writer, "OpenOutputs=1")?;
365        writeln!(writer, "ArchiveProject=0")?;
366        writeln!(writer, "TimestampOutput=0")?;
367        writeln!(writer, "ManagedProjectGuid=")?;
368        writeln!(writer, "Variants=")?;
369        writeln!(writer)?;
370
371        // Document sections
372        for (i, doc) in self.documents.iter().enumerate() {
373            writeln!(writer, "[Document{}]", i + 1)?;
374            writeln!(writer, "DocumentPath={}", doc.path)?;
375            writeln!(
376                writer,
377                "AnnotationEnabled={}",
378                if doc.annotation_enabled { "1" } else { "0" }
379            )?;
380            writeln!(writer, "AnnotateStartValue={}", doc.annotation_start_value)?;
381            writeln!(writer, "AnnotationIndexControlEnabled=0")?;
382            writeln!(writer, "AnnotateSuffix=")?;
383            writeln!(writer, "AnnotateScope=0")?;
384            writeln!(writer, "AnnotateOrder=-1")?;
385            writeln!(
386                writer,
387                "DoLibraryUpdate={}",
388                if doc.do_library_update { "1" } else { "0" }
389            )?;
390            writeln!(
391                writer,
392                "DoDatabaseUpdate={}",
393                if doc.do_database_update { "1" } else { "0" }
394            )?;
395            writeln!(writer, "ClassGenCCAutoEnabled=1")?;
396            writeln!(writer, "ClassGenCCAutoRoomEnabled=1")?;
397            writeln!(writer, "ClassGenNCAutoScope=0")?;
398            writeln!(writer, "DItemRevisionGUID=")?;
399            writeln!(writer)?;
400        }
401
402        // [GeneratedDocuments]
403        writeln!(writer, "[GeneratedDocuments]")?;
404        writeln!(writer)?;
405
406        // [ProjectVariantGroups]
407        writeln!(writer, "[ProjectVariantGroups]")?;
408        writeln!(writer)?;
409
410        // [ERC Connection Matrix]
411        writeln!(writer, "[ERC Connection Matrix]")?;
412        let default_erc = vec![
413            "NNNNNNNNNNNWNNNWW",
414            "NNWNNNNWNWNWNWNWN",
415            "NWEABOROBWBWRORNB",
416            "NNAABOROBWBWBORNB",
417            "NNBBNNBNNWNWBNNNN",
418            "NNOOOROOONNWOONOO",
419            "NNRBBNRNNWNWRBNRN",
420            "NWOOOOOOOWOWNOOOO",
421            "NNBBNNBNNWNWBNNNN",
422            "NWWWWNWWWNWWWWNWW",
423            "WBBBBOBBBBBWBBBBB",
424            "NWWWWNWWWNWWWWNWW",
425            "WWRRRORRRWRWRRNRR",
426            "NNBBNNBNNWNWBNNNN",
427            "NNOOOROOONNWOONOO",
428            "WWNRNNRNRWRWRNRRR",
429            "WNNNNNNNNNBNNNNRN",
430        ];
431        let rows = if self.erc_matrix.rows.is_empty() {
432            &default_erc
433        } else {
434            &self
435                .erc_matrix
436                .rows
437                .iter()
438                .map(|s| s.as_str())
439                .collect::<Vec<_>>()
440        };
441        for (i, row) in rows.iter().enumerate() {
442            writeln!(writer, "L{}={}", i + 1, row)?;
443        }
444        writeln!(writer)?;
445
446        // [ProjectOptions]
447        writeln!(writer, "[ProjectOptions]")?;
448        writeln!(writer, "IncludeDesignatorInPinUniqueIDNumber=0")?;
449        writeln!(writer, "IncludePartNumberInPinUniqueIDNumber=0")?;
450        writeln!(writer, "EnableConstraintManager=0")?;
451        writeln!(writer, "ComponentNamingScheme=0")?;
452        writeln!(writer, "PadNamingScheme=0")?;
453        writeln!(writer, "ComponentAutoZoom=1")?;
454        writeln!(writer, "ShowSheetNumberInSheetSymbolPad=0")?;
455        writeln!(writer, "OpenSchematicInServerForce=0")?;
456        writeln!(writer, "LocalCompilerGUID=")?;
457        writeln!(writer)?;
458
459        // [Parameters]
460        writeln!(writer, "[Parameters]")?;
461        for (key, value) in &self.parameters {
462            writeln!(writer, "{}={}", key, value)?;
463        }
464        writeln!(writer)?;
465
466        // [Workspace]
467        writeln!(writer, "[Workspace]")?;
468        writeln!(writer)?;
469
470        // [Configuration Constraints]
471        writeln!(writer, "[Configuration Constraints]")?;
472        writeln!(writer)?;
473
474        Ok(())
475    }
476
477    /// Save the project to a file path.
478    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
479        let file = File::create(path)?;
480        self.save(file)
481    }
482
483    // ═══════════════════════════════════════════════════════════════════════════
484    // DOCUMENT MANAGEMENT
485    // ═══════════════════════════════════════════════════════════════════════════
486
487    /// Add a document to the project.
488    pub fn add_document(&mut self, path: impl Into<String>) -> &mut ProjectDocument {
489        let path_str = path.into();
490        let doc = ProjectDocument {
491            doc_type: DocumentType::from_path(&path_str),
492            path: path_str,
493            annotation_enabled: true,
494            annotation_start_value: 1,
495            do_library_update: true,
496            do_database_update: true,
497            params: HashMap::new(),
498        };
499        self.documents.push(doc);
500        self.documents.last_mut().unwrap()
501    }
502
503    /// Remove a document from the project by path.
504    pub fn remove_document(&mut self, path: &str) -> bool {
505        let original_len = self.documents.len();
506        self.documents.retain(|d| d.path != path);
507        self.documents.len() != original_len
508    }
509
510    /// Get a document by path.
511    pub fn get_document(&self, path: &str) -> Option<&ProjectDocument> {
512        self.documents.iter().find(|d| d.path == path)
513    }
514
515    /// Get a mutable document by path.
516    pub fn get_document_mut(&mut self, path: &str) -> Option<&mut ProjectDocument> {
517        self.documents.iter_mut().find(|d| d.path == path)
518    }
519
520    /// Get all schematic documents.
521    pub fn schematics(&self) -> Vec<&ProjectDocument> {
522        self.documents
523            .iter()
524            .filter(|d| d.doc_type == DocumentType::Schematic)
525            .collect()
526    }
527
528    /// Get all PCB documents.
529    pub fn pcb_documents(&self) -> Vec<&ProjectDocument> {
530        self.documents
531            .iter()
532            .filter(|d| d.doc_type == DocumentType::Pcb)
533            .collect()
534    }
535
536    /// Get the primary PCB document (first one found).
537    pub fn primary_pcb(&self) -> Option<&ProjectDocument> {
538        self.documents
539            .iter()
540            .find(|d| d.doc_type == DocumentType::Pcb)
541    }
542
543    /// Get the project name from parameters or file name.
544    pub fn name(&self) -> String {
545        if let Some(name) = self.parameters.get("Name") {
546            name.clone()
547        } else if let Some(ref path) = self.path {
548            path.file_stem()
549                .and_then(|s| s.to_str())
550                .unwrap_or("Unnamed")
551                .to_string()
552        } else {
553            "Unnamed".to_string()
554        }
555    }
556
557    /// Set the project name.
558    pub fn set_name(&mut self, name: impl Into<String>) {
559        self.parameters.insert("Name".to_string(), name.into());
560    }
561
562    // ═══════════════════════════════════════════════════════════════════════════
563    // PARAMETER MANAGEMENT
564    // ═══════════════════════════════════════════════════════════════════════════
565
566    /// Get a project parameter.
567    pub fn get_parameter(&self, key: &str) -> Option<&String> {
568        self.parameters.get(key)
569    }
570
571    /// Set a project parameter.
572    pub fn set_parameter(&mut self, key: impl Into<String>, value: impl Into<String>) {
573        self.parameters.insert(key.into(), value.into());
574    }
575
576    /// Remove a project parameter.
577    pub fn remove_parameter(&mut self, key: &str) -> Option<String> {
578        self.parameters.remove(key)
579    }
580}
581
582impl DumpTree for PrjPcb {
583    fn dump(&self, tree: &mut TreeBuilder) {
584        tree.root(&format!(
585            "Project: {} ({} documents)",
586            self.name(),
587            self.documents.len()
588        ));
589
590        // Info section
591        tree.push(!self.documents.is_empty());
592        let info_props = vec![
593            ("version", self.version.clone()),
594            (
595                "hierarchy",
596                if self.hierarchy_mode == 0 {
597                    "Flat".to_string()
598                } else {
599                    "Hierarchical".to_string()
600                },
601            ),
602            ("output_path", self.output_path.clone()),
603        ];
604        tree.add_leaf("Info", &info_props);
605        tree.pop();
606
607        // Documents section
608        if !self.documents.is_empty() {
609            tree.push(!self.parameters.is_empty());
610            tree.begin_node(&format!("Documents ({})", self.documents.len()));
611            for (i, doc) in self.documents.iter().enumerate() {
612                tree.push(i < self.documents.len() - 1);
613                let doc_props = vec![
614                    ("type", doc.doc_type.display_name().to_string()),
615                    (
616                        "annotation",
617                        if doc.annotation_enabled {
618                            "enabled".to_string()
619                        } else {
620                            "disabled".to_string()
621                        },
622                    ),
623                ];
624                tree.add_leaf(&doc.path, &doc_props);
625                tree.pop();
626            }
627            tree.pop();
628        }
629
630        // Parameters section
631        if !self.parameters.is_empty() {
632            tree.push(false);
633            tree.begin_node(&format!("Parameters ({})", self.parameters.len()));
634            let mut params: Vec<_> = self.parameters.iter().collect();
635            params.sort_by_key(|(k, _)| k.as_str());
636            for (i, (key, value)) in params.iter().enumerate() {
637                tree.push(i < params.len() - 1);
638                tree.add_leaf(key, &[("value", value.to_string())]);
639                tree.pop();
640            }
641            tree.pop();
642        }
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_document_type_from_path() {
652        assert_eq!(
653            DocumentType::from_path("Sheet1.SchDoc"),
654            DocumentType::Schematic
655        );
656        assert_eq!(DocumentType::from_path("PCB1.PcbDoc"), DocumentType::Pcb);
657        assert_eq!(
658            DocumentType::from_path("Library.SchLib"),
659            DocumentType::SchLib
660        );
661        assert_eq!(
662            DocumentType::from_path("Library.PcbLib"),
663            DocumentType::PcbLib
664        );
665        assert_eq!(DocumentType::from_path("test.txt"), DocumentType::Other);
666    }
667
668    #[test]
669    fn test_parse_project() {
670        let content = r#"[Design]
671Version=1.0
672HierarchyMode=0
673OutputPath=Project Outputs\
674AnnotationStartValue=1
675
676[Document1]
677DocumentPath=Sheet1.SchDoc
678AnnotationEnabled=1
679
680[Document2]
681DocumentPath=PCB1.PcbDoc
682AnnotationEnabled=1
683
684[Parameters]
685Name=TestProject
686"#;
687
688        let prj = PrjPcb::open(content.as_bytes()).unwrap();
689        assert_eq!(prj.version, "1.0");
690        assert_eq!(prj.hierarchy_mode, 0);
691        assert_eq!(prj.documents.len(), 2);
692        assert_eq!(prj.documents[0].path, "Sheet1.SchDoc");
693        assert_eq!(prj.documents[0].doc_type, DocumentType::Schematic);
694        assert_eq!(prj.documents[1].path, "PCB1.PcbDoc");
695        assert_eq!(prj.documents[1].doc_type, DocumentType::Pcb);
696        assert_eq!(prj.name(), "TestProject");
697    }
698}