Skip to main content

archidoc_rust/
parser.rs

1use std::fs;
2use std::path::Path;
3
4use archidoc_types::{
5    C4Level, FileEntry, HealthStatus, PatternStatus, Relationship,
6};
7
8/// Extract `//!` doc comments from a Rust source file.
9///
10/// Returns the joined content of all leading `//!` lines, with prefixes stripped.
11pub fn archidoc_from_file(path: &Path) -> Option<String> {
12    let content = fs::read_to_string(path).ok()?;
13
14    let doc_lines: Vec<&str> = content
15        .lines()
16        .take_while(|line| {
17            let trimmed = line.trim();
18            trimmed.starts_with("//!") || trimmed.is_empty()
19        })
20        .filter(|line| line.trim().starts_with("//!"))
21        .map(|line| {
22            let trimmed = line.trim();
23            if trimmed == "//!" {
24                ""
25            } else if let Some(rest) = trimmed.strip_prefix("//! ") {
26                rest
27            } else {
28                trimmed.strip_prefix("//!").unwrap_or("")
29            }
30        })
31        .collect();
32
33    if doc_lines.is_empty() {
34        None
35    } else {
36        Some(doc_lines.join("\n"))
37    }
38}
39
40/// Extract the C4 level marker from doc content.
41///
42/// Uses `@c4 container` / `@c4 component` syntax.
43pub fn extract_c4_level(content: &str) -> C4Level {
44    if content.contains("@c4 container") {
45        C4Level::Container
46    } else if content.contains("@c4 component") {
47        C4Level::Component
48    } else {
49        C4Level::Unknown
50    }
51}
52
53/// Extract the primary GoF pattern name from doc content.
54///
55/// Looks for known pattern names in the content. Returns the first match
56/// or "--" if none found.
57pub fn extract_pattern(content: &str) -> String {
58    let patterns = [
59        "Mediator",
60        "Observer",
61        "Strategy",
62        "Facade",
63        "Adapter",
64        "Repository",
65        "Singleton",
66        "Factory",
67        "Builder",
68        "Decorator",
69        "Active Object",
70        "Memento",
71        "Command",
72        "Chain of Responsibility",
73        "Registry",
74        "Composite",
75        "Interpreter",
76        "Flyweight",
77        "Publisher",
78    ];
79
80    for name in patterns {
81        if content.contains(name) {
82            return name.to_string();
83        }
84    }
85
86    "--".to_string()
87}
88
89/// Extract pattern status from doc content.
90///
91/// Looks for "(verified)" near a pattern name. Defaults to Planned.
92pub fn extract_pattern_status(content: &str) -> PatternStatus {
93    if content.contains("(verified)") {
94        PatternStatus::Verified
95    } else {
96        PatternStatus::Planned
97    }
98}
99
100/// Extract the first non-header, non-marker line as description.
101pub fn extract_description(content: &str) -> String {
102    content
103        .lines()
104        .find(|l| {
105            let trimmed = l.trim();
106            !trimmed.is_empty()
107                && !trimmed.starts_with('#')
108                && !trimmed.starts_with("@c4 ")
109                && !trimmed.starts_with('|')
110                && !trimmed.starts_with("GoF:")
111        })
112        .unwrap_or("*No description*")
113        .trim()
114        .to_string()
115}
116
117/// Extract the parent container from a dot-notation module path.
118///
119/// "bus.calc.indicators" -> Some("bus")
120/// "bus" -> None
121pub fn extract_parent_container(module_path: &str) -> Option<String> {
122    if module_path.contains('.') {
123        Some(
124            module_path
125                .split('.')
126                .next()
127                .unwrap_or(module_path)
128                .to_string(),
129        )
130    } else {
131        None
132    }
133}
134
135/// Parse the markdown file table into FileEntry structs.
136///
137/// Expects format:
138/// ```text
139/// | File | Pattern | Purpose | Health |
140/// |------|---------|---------|--------|
141/// | `core.rs` | Facade | Entry point | stable |
142/// ```
143pub fn extract_file_table(content: &str) -> Vec<FileEntry> {
144    let mut entries = Vec::new();
145    let mut in_table = false;
146    let mut header_seen = false;
147
148    for line in content.lines() {
149        let trimmed = line.trim();
150
151        if !in_table {
152            // Look for table header
153            if trimmed.starts_with('|')
154                && (trimmed.contains("File") || trimmed.contains("file"))
155                && (trimmed.contains("Pattern") || trimmed.contains("pattern"))
156            {
157                in_table = true;
158                continue;
159            }
160        } else if !header_seen {
161            // Skip the separator row (|------|...)
162            if trimmed.starts_with('|') && trimmed.contains("---") {
163                header_seen = true;
164                continue;
165            }
166        } else {
167            // Parse data rows
168            if !trimmed.starts_with('|') {
169                break; // End of table
170            }
171
172            let cells: Vec<&str> = trimmed
173                .split('|')
174                .filter(|s| !s.trim().is_empty())
175                .map(|s| s.trim())
176                .collect();
177
178            if cells.len() >= 4 {
179                let filename = cells[0]
180                    .trim_matches('`')
181                    .trim()
182                    .to_string();
183
184                let (pattern, pattern_status) = parse_pattern_field(cells[1]);
185                let purpose = cells[2].trim().to_string();
186                let health = HealthStatus::parse(cells[3]);
187
188                entries.push(FileEntry {
189                    name: filename,
190                    pattern,
191                    pattern_status,
192                    purpose,
193                    health,
194                });
195            }
196        }
197    }
198
199    entries
200}
201
202/// Parse a pattern field like "Strategy (verified)" into (pattern, status).
203fn parse_pattern_field(field: &str) -> (String, PatternStatus) {
204    let trimmed = field.trim();
205
206    if let Some(idx) = trimmed.find('(') {
207        let pattern = trimmed[..idx].trim().to_string();
208        let status_str = trimmed[idx + 1..]
209            .trim_end_matches(')')
210            .trim();
211        (pattern, PatternStatus::parse(status_str))
212    } else {
213        (trimmed.to_string(), PatternStatus::Planned)
214    }
215}
216
217/// Parse `@c4 uses target "label" "protocol"` markers from content.
218pub fn extract_relationships(content: &str) -> Vec<Relationship> {
219    let mut rels = Vec::new();
220
221    for line in content.lines() {
222        let trimmed = line.trim();
223        if let Some(rest) = trimmed.strip_prefix("@c4 uses ") {
224            // Parse: target "label" "protocol"
225            // Split on first quote to get target, then extract quoted strings
226            if let Some(quote_start) = rest.find('"') {
227                let target = rest[..quote_start].trim().to_string();
228                let quoted_part = &rest[quote_start..];
229                let quotes: Vec<&str> = quoted_part
230                    .split('"')
231                    .filter(|s| !s.trim().is_empty())
232                    .collect();
233                if quotes.len() >= 2 {
234                    rels.push(Relationship {
235                        target,
236                        label: quotes[0].to_string(),
237                        protocol: quotes[1].to_string(),
238                    });
239                }
240            }
241        }
242    }
243
244    rels
245}