Skip to main content

nucleus_compiler/
solver.rs

1//! The hardware constraint solver.
2//!
3//! Takes a parsed [`Config`] and a [`Database`] and produces a list of
4//! [`Conflict`]s. Phase 2 detects exactly the four conflict classes from the
5//! README roadmap:
6//!
7//! 1. **Pin collision** — two peripheral signals on one physical pin.
8//! 2. **AF mismatch** — a pin assigned to a peripheral it doesn't connect to.
9//! 3. **Missing required pin** — a peripheral declared without a required pin.
10//! 4. **Clock domain disabled** — a peripheral whose bus clock is turned off.
11//!
12//! Per the scope rules there is no DMA-collision or full clock-tree analysis.
13
14use std::collections::BTreeMap;
15use std::fmt;
16use std::str::FromStr;
17
18use nucleus_db::{Database, Pin};
19
20use crate::config::Config;
21use crate::model::{self, Bus};
22
23/// A single resolved conflict. Every variant is an error (it makes the config
24/// un-buildable); `nucleus check` exits non-zero if any are present.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Conflict {
27    /// Two signals assigned to the same physical pin.
28    PinCollision {
29        pin: Pin,
30        /// The colliding `(peripheral, signal)` users of the pin, sorted.
31        users: Vec<SignalRef>,
32    },
33    /// A pin that does not expose the requested peripheral signal on this MCU.
34    AfMismatch {
35        pin: Pin,
36        peripheral: String,
37        signal: String,
38    },
39    /// A pin role whose string value is not a valid pin name.
40    InvalidPin {
41        peripheral: String,
42        key: String,
43        value: String,
44    },
45    /// A required pin role left unset.
46    MissingPin {
47        peripheral: String,
48        key: String,
49        signal: String,
50    },
51    /// A peripheral configured while its bus clock domain is disabled.
52    ClockDomainDisabled { peripheral: String, bus: Bus },
53}
54
55/// A `(peripheral, signal)` pair identifying one use of a pin.
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
57pub struct SignalRef {
58    pub peripheral: String,
59    pub signal: String,
60}
61
62impl fmt::Display for SignalRef {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(f, "{}_{}", self.peripheral, self.signal)
65    }
66}
67
68impl fmt::Display for Conflict {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Conflict::PinCollision { pin, users } => {
72                let names: Vec<String> = users.iter().map(ToString::to_string).collect();
73                write!(
74                    f,
75                    "pin collision on {pin}: assigned to {}",
76                    names.join(" and ")
77                )
78            }
79            Conflict::AfMismatch {
80                pin,
81                peripheral,
82                signal,
83            } => write!(
84                f,
85                "AF mismatch: {pin} has no alternate function for {peripheral}_{signal} on this MCU"
86            ),
87            Conflict::InvalidPin {
88                peripheral,
89                key,
90                value,
91            } => write!(
92                f,
93                "invalid pin: {peripheral}.{key} = {value:?} is not a valid pin name"
94            ),
95            Conflict::MissingPin {
96                peripheral,
97                key,
98                signal,
99            } => write!(
100                f,
101                "missing required pin: {peripheral} needs a {key} pin ({peripheral}_{signal})"
102            ),
103            Conflict::ClockDomainDisabled { peripheral, bus } => write!(
104                f,
105                "clock domain disabled: {peripheral} is on {} but [clocks].{} = false",
106                bus.name(),
107                bus.name().to_ascii_lowercase()
108            ),
109        }
110    }
111}
112
113/// Run the solver over `config` against `db`, returning all conflicts in a
114/// deterministic order (so output and tests are stable across runs).
115pub fn solve(config: &Config, db: &Database) -> Vec<Conflict> {
116    let mut conflicts = Vec::new();
117    // pin -> the signals assigned to it, for collision detection.
118    let mut pin_users: BTreeMap<Pin, Vec<SignalRef>> = BTreeMap::new();
119
120    // BTreeMap iteration is lexical, giving deterministic ordering.
121    for (instance, table) in &config.peripherals {
122        let Some(roles) = model::roles_for(instance) else {
123            // Unmodelled peripheral kind: nothing to check.
124            continue;
125        };
126        let peripheral = model::peripheral_name(instance);
127
128        // Clock-domain check: one diagnostic per peripheral, before pin work.
129        if let Some(bus) = model::peripheral_bus(&peripheral) {
130            let enabled = match bus {
131                Bus::Ahb1 => config.clocks.ahb1,
132                Bus::Apb1 => config.clocks.apb1,
133                Bus::Apb2 => config.clocks.apb2,
134            };
135            if !enabled {
136                conflicts.push(Conflict::ClockDomainDisabled {
137                    peripheral: peripheral.clone(),
138                    bus,
139                });
140            }
141        }
142
143        for role in roles {
144            match table.pin_str(role.key) {
145                None => {
146                    if role.required {
147                        conflicts.push(Conflict::MissingPin {
148                            peripheral: peripheral.clone(),
149                            key: role.key.to_string(),
150                            signal: role.signal.to_string(),
151                        });
152                    }
153                }
154                Some(value) => {
155                    let Ok(pin) = Pin::from_str(value) else {
156                        conflicts.push(Conflict::InvalidPin {
157                            peripheral: peripheral.clone(),
158                            key: role.key.to_string(),
159                            value: value.to_string(),
160                        });
161                        continue;
162                    };
163                    // AF mismatch: does this pin actually expose this signal?
164                    if db.find_af(pin, &peripheral, role.signal).is_none() {
165                        conflicts.push(Conflict::AfMismatch {
166                            pin,
167                            peripheral: peripheral.clone(),
168                            signal: role.signal.to_string(),
169                        });
170                    }
171                    // Record for collision detection regardless of AF validity:
172                    // two peripherals fighting over a pin is worth reporting even
173                    // if one of them is also mis-wired.
174                    pin_users.entry(pin).or_default().push(SignalRef {
175                        peripheral: peripheral.clone(),
176                        signal: role.signal.to_string(),
177                    });
178                }
179            }
180        }
181    }
182
183    // One PinCollision per over-subscribed pin (not per pair), so a doubly-used
184    // pin yields exactly one error.
185    for (pin, mut users) in pin_users {
186        if users.len() > 1 {
187            users.sort();
188            conflicts.push(Conflict::PinCollision { pin, users });
189        }
190    }
191
192    conflicts
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::config;
199
200    fn db() -> Database {
201        Database::f446re()
202    }
203
204    fn solve_toml(text: &str) -> Vec<Conflict> {
205        let cfg = config::parse(text).unwrap();
206        solve(&cfg, &db())
207    }
208
209    #[test]
210    fn clean_config_has_no_conflicts() {
211        let conflicts = solve_toml(
212            r#"
213[peripherals.usart2]
214tx = "PA2"
215rx = "PA3"
216
217[peripherals.spi1]
218mosi = "PA7"
219miso = "PA6"
220sck = "PA5"
221nss = "PA4"
222
223[peripherals.i2c1]
224sda = "PB9"
225scl = "PB8"
226"#,
227        );
228        assert_eq!(
229            conflicts,
230            vec![],
231            "expected clean config, got {conflicts:?}"
232        );
233    }
234
235    #[test]
236    fn detects_pin_collision() {
237        // PA5 is SPI1_SCK and also (wrongly) USART2... we put two real signals
238        // on PA5 to force a collision.
239        let conflicts = solve_toml(
240            r#"
241[peripherals.spi1]
242mosi = "PA7"
243miso = "PA6"
244sck = "PA5"
245
246[peripherals.tim2]
247channel1 = "PA5"
248"#,
249        );
250        let collisions: Vec<_> = conflicts
251            .iter()
252            .filter(|c| matches!(c, Conflict::PinCollision { .. }))
253            .collect();
254        assert_eq!(collisions.len(), 1, "got {conflicts:?}");
255        if let Conflict::PinCollision { pin, users } = collisions[0] {
256            assert_eq!(pin.to_string(), "PA5");
257            assert_eq!(users.len(), 2);
258        }
259    }
260
261    #[test]
262    fn detects_af_mismatch() {
263        // PB0 does not carry USART2_TX on the F446.
264        let conflicts = solve_toml(
265            r#"
266[peripherals.usart2]
267tx = "PB0"
268rx = "PA3"
269"#,
270        );
271        assert!(
272            conflicts.iter().any(|c| matches!(
273                c,
274                Conflict::AfMismatch { pin, signal, .. }
275                    if pin.to_string() == "PB0" && signal == "TX"
276            )),
277            "got {conflicts:?}"
278        );
279    }
280
281    #[test]
282    fn detects_missing_required_pin() {
283        // SPI1 without MOSI.
284        let conflicts = solve_toml(
285            r#"
286[peripherals.spi1]
287miso = "PA6"
288sck = "PA5"
289"#,
290        );
291        assert!(
292            conflicts.iter().any(|c| matches!(
293                c,
294                Conflict::MissingPin { peripheral, signal, .. }
295                    if peripheral == "SPI1" && signal == "MOSI"
296            )),
297            "got {conflicts:?}"
298        );
299    }
300
301    #[test]
302    fn missing_optional_pin_is_not_a_conflict() {
303        // SPI1 without NSS (optional) is fine.
304        let conflicts = solve_toml(
305            r#"
306[peripherals.spi1]
307mosi = "PA7"
308miso = "PA6"
309sck = "PA5"
310"#,
311        );
312        assert_eq!(conflicts, vec![]);
313    }
314
315    #[test]
316    fn detects_clock_domain_disabled() {
317        // SPI1 lives on APB2; disabling APB2 must flag it.
318        let conflicts = solve_toml(
319            r#"
320[clocks]
321apb2 = false
322
323[peripherals.spi1]
324mosi = "PA7"
325miso = "PA6"
326sck = "PA5"
327"#,
328        );
329        assert!(
330            conflicts.iter().any(|c| matches!(
331                c,
332                Conflict::ClockDomainDisabled { peripheral, bus }
333                    if peripheral == "SPI1" && *bus == Bus::Apb2
334            )),
335            "got {conflicts:?}"
336        );
337    }
338
339    #[test]
340    fn invalid_pin_name_reported() {
341        let conflicts = solve_toml(
342            r#"
343[peripherals.usart2]
344tx = "PZ9"
345rx = "PA3"
346"#,
347        );
348        assert!(
349            conflicts
350                .iter()
351                .any(|c| matches!(c, Conflict::InvalidPin { value, .. } if value == "PZ9")),
352            "got {conflicts:?}"
353        );
354    }
355}