ferroforge-cli 0.2.0

The ferroforge command: create, check, build and flash FerroForge RTIC firmware
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! `ferroforge new` and `ferroforge add`: firmware that builds before you have
//! written anything.
//!
//! The layout it writes is the convention the rest of the CLI recognizes -
//! `firmware/` holding applications, `tasks/` holding reusable definitions -
//! and nothing more. There is no marker file to create, so a project made by
//! hand is indistinguishable from one made here.

use std::{fmt, fs, io, path::Path};

use crate::backend::{self, Backend};

#[derive(Debug)]
pub enum Error {
    Exists { path: String },
    InvalidName { name: String, reason: &'static str },
    UnknownChip { chip: String, known: String },
    NoStarter { chip: String },
    Backend(backend::Error),
    Write { path: String, source: io::Error },
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Exists { path } => {
                write!(formatter, "{path} already exists")
            }
            Self::InvalidName { name, reason } => {
                write!(formatter, "`{name}` cannot name a firmware: {reason}")
            }
            Self::UnknownChip { chip, known } => write!(
                formatter,
                "no backend for chip `{chip}`. FerroForge ships: {known}"
            ),
            Self::NoStarter { chip } => write!(
                formatter,
                "FerroForge knows `{chip}` but has no starting firmware for its HAL"
            ),
            Self::Backend(error) => write!(formatter, "{error}"),
            Self::Write { path, source } => write!(formatter, "cannot write {path}: {source}"),
        }
    }
}

impl std::error::Error for Error {}

fn write(path: &Path, contents: &str) -> Result<(), Error> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|source| Error::Write {
            path: parent.display().to_string(),
            source,
        })?;
    }
    fs::write(path, contents).map_err(|source| Error::Write {
        path: path.display().to_string(),
        source,
    })
}

/// The backend for a chip and the starting firmware for its HAL. Both are
/// needed before anything is written, so a chip that cannot be started is
/// refused up front rather than leaving half a firmware behind.
fn resolve(chip: &str) -> Result<(Backend, &'static Starter), Error> {
    let backend = Backend::for_chip(chip).map_err(|error| match error {
        backend::Error::UnknownChip { chip, known } => Error::UnknownChip { chip, known },
        other => Error::Backend(other),
    })?;
    let starter = starter(&backend).ok_or_else(|| Error::NoStarter {
        chip: chip.to_owned(),
    })?;
    Ok((backend, starter))
}

/// The task crate `new` writes and every firmware it or `add` writes selects
/// from. A fixed name, not the project's: `add` has to find it again, and a
/// project directory can be renamed.
const TASK_CRATE: &str = "heartbeat";

/// `ferroforge` is how the generated manifests depend on FerroForge: the
/// published version requirement, or a path to a checkout.
pub fn create(root: &Path, chip: &str, ferroforge: &str) -> Result<(), Error> {
    if root.exists() {
        return Err(Error::Exists {
            path: root.display().to_string(),
        });
    }
    let (backend, starter) = resolve(chip)?;

    let name = root
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
        .unwrap_or_else(|| "firmware".to_owned());
    validate_name(&name)?;
    let task_crate = TASK_CRATE;

    let derived = write_firmware(
        &root.join("firmware").join(&name),
        &name,
        chip,
        &backend,
        starter,
        task_crate,
        ferroforge,
    )?;
    write_task_crate(root, task_crate, ferroforge)?;
    write(&root.join(".gitignore"), "target/\n")?;

    let shown = root.display().to_string().replace('\\', "/");
    println!("created {shown}");
    println!("  firmware/{name}/ for {}", backend.chip.name);
    println!("  tasks/{task_crate}/");
    println!("  {derived} files derived from the chip, already written");
    println!("\nnext: cd {shown} && ferroforge run");
    Ok(())
}

/// `ferroforge add`: another firmware in a project that already exists.
///
/// It selects `heartbeat` from the task crate `new` wrote, and writes no tasks
/// of its own: the task crate is authored code by now, and a firmware reusing
/// a definition is the point. If that crate has since been removed, the new
/// firmware fails to build in Cargo, on the dependency it names.
///
/// `ferroforge` is how its manifest depends on FerroForge. Without one it is
/// taken from a firmware already in the project, so a project made against a
/// checkout stays on that checkout.
pub fn add(root: &Path, name: &str, chip: &str, ferroforge: Option<&str>) -> Result<(), Error> {
    validate_name(name)?;
    let firmware = root.join("firmware").join(name);
    if firmware.exists() {
        return Err(Error::Exists {
            path: format!("firmware/{name}"),
        });
    }
    let (backend, starter) = resolve(chip)?;

    let ferroforge = match ferroforge {
        Some(dependency) => dependency.to_owned(),
        None => inherited_dependency(root).unwrap_or_else(|| PUBLISHED.to_owned()),
    };
    let task_crate = TASK_CRATE;
    let derived = write_firmware(
        &firmware,
        name,
        chip,
        &backend,
        starter,
        task_crate,
        &ferroforge,
    )?;

    println!("added firmware/{name}/ for {}", backend.chip.name);
    println!("  selects `heartbeat` from tasks/{task_crate}/");
    println!("  {derived} files derived from the chip, already written");
    println!("\nnext: ferroforge run {name}");
    Ok(())
}

/// The published crate, as a manifest value.
pub const PUBLISHED: &str = "\"0.2\"";

/// The `ferroforge = ...` value of the first firmware in the project that has
/// one. Firmwares all sit at `firmware/<name>/`, so a relative path means the
/// same thing copied into a sibling.
fn inherited_dependency(root: &Path) -> Option<String> {
    let mut manifests = fs::read_dir(root.join("firmware"))
        .ok()?
        .flatten()
        .map(|entry| entry.path().join("Cargo.toml"))
        .collect::<Vec<_>>();
    manifests.sort();
    manifests.iter().find_map(|manifest| {
        let text = fs::read_to_string(manifest).ok()?;
        let mut in_dependencies = false;
        text.lines().find_map(|line| {
            let line = line.trim();
            if line.starts_with('[') {
                in_dependencies = line == "[dependencies]";
                return None;
            }
            let (key, value) = line.split_once('=')?;
            (in_dependencies && key.trim() == "ferroforge").then(|| value.trim().to_owned())
        })
    })
}

/// The name becomes a directory, a Cargo package and a binary, so it has to be
/// all three. Cargo would refuse a bad one later, but from inside a build and
/// after the directory had been written.
fn validate_name(name: &str) -> Result<(), Error> {
    let refuse = |reason| {
        Err(Error::InvalidName {
            name: name.to_owned(),
            reason,
        })
    };
    let Some(first) = name.chars().next() else {
        return refuse("it is empty");
    };
    if !first.is_ascii_alphabetic() {
        return refuse("it must start with a letter");
    }
    if !name
        .chars()
        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
    {
        return refuse("use letters, digits, `-` and `_` only");
    }
    if name == TASK_CRATE {
        return refuse("it is the task crate's name, and a package cannot depend on its own name");
    }
    Ok(())
}

/// One firmware: its manifest, its source, and everything its chip implies.
/// Returns how many files came from the chip.
fn write_firmware(
    firmware: &Path,
    name: &str,
    chip: &str,
    backend: &Backend,
    starter: &Starter,
    task_crate: &str,
    ferroforge: &str,
) -> Result<usize, Error> {
    write(
        &firmware.join("Cargo.toml"),
        &firmware_manifest(name, chip, task_crate, ferroforge),
    )?;
    write(
        &firmware.join("src/main.rs"),
        &main_rs(backend, starter, task_crate),
    )?;
    // Written by the same code a later `sync` uses, so a new firmware is
    // already in the state `sync` would leave it.
    let written = backend.emit(firmware, "info").map_err(Error::Backend)?;
    Ok(written.len())
}

fn write_task_crate(root: &Path, task_crate: &str, ferroforge: &str) -> Result<(), Error> {
    let tasks = root.join("tasks").join(task_crate);
    write(
        &tasks.join("Cargo.toml"),
        &task_manifest(task_crate, ferroforge),
    )?;
    write(&tasks.join("src/lib.rs"), TASK_LIB)
}

fn firmware_manifest(name: &str, chip: &str, task_crate: &str, ferroforge: &str) -> String {
    format!(
        "[package]\n\
         name = \"{name}\"\n\
         version = \"0.1.0\"\n\
         edition = \"2024\"\n\
         publish = false\n\
         build = false\n\n\
         [package.metadata.ferroforge]\n\
         # The chip this firmware is built for. Everything the chip implies is\n\
         # derived from this, and nothing else records it.\n\
         chip = \"{chip}\"\n\n\
         [[bin]]\n\
         name = \"{name}\"\n\
         path = \"src/main.rs\"\n\
         test = false\n\
         bench = false\n\n\
         [dependencies]\n\
         ferroforge = {ferroforge}\n\
         {task_crate} = {{ path = \"../../tasks/{task_crate}\" }}\n\n\
         # This application's own choices: what it logs with and how it panics.\n\
         defmt = \"1.0.1\"\n\
         defmt-rtt = \"1.3.0\"\n\
         panic-probe = {{ version = \"1.0.0\", features = [\"print-defmt\"] }}\n\n\
         # ferroforge:platform-dependencies\n\
         # ferroforge:end\n\n\
         [profile.release]\n\
         codegen-units = 1\n\
         debug = 2\n\
         lto = true\n\
         opt-level = \"s\"\n\n\
         [workspace]\n"
    )
}

fn task_manifest(task_crate: &str, ferroforge: &str) -> String {
    format!(
        "[package]\n\
         name = \"{task_crate}\"\n\
         version = \"0.1.0\"\n\
         edition = \"2024\"\n\
         publish = false\n\n\
         [lib]\n\
         test = false\n\
         bench = false\n\n\
         [dependencies]\n\
         ferroforge = {ferroforge}\n\
         defmt = \"1.0.1\"\n\
         fugit = \"0.3.9\"\n\
         rtic = {{ version = \"2.3.1\", default-features = false, features = [\"thumbv7-backend\"] }}\n\
         rtic-monotonics = {{ version = \"2.2.1\", default-features = false, features = [\"cortex-m-systick\"] }}\n\n\
         [package.metadata.ferroforge]\n\
         check-only-dependencies = [\"ferroforge\"]\n\n\
         [workspace]\n"
    )
}

const TASK_LIB: &str = "\
//! Reusable task definitions. Nothing here names a chip or a HAL, so these
//! compile on their own and any firmware can select them.

#![no_std]

use fugit::ExtU32 as _;

/// Logs a count at a fixed period, forever. It needs no pins, so it runs on
/// any board, and `ferroforge run` shows what it prints.
#[ferroforge::task(
    local = [count: u32],
    config = [period_ms: u32],
    monotonic = Mono,
)]
pub async fn heartbeat(cx: heartbeat::Context) -> ! {
    loop {
        *cx.local.count = cx.local.count.wrapping_add(1);
        defmt::info!(\"heartbeat {=u32}\", *cx.local.count);
        Mono::delay(CONFIG.PERIOD_MS.millis()).await;
    }
}
";

/// What a new firmware needs that depends on its HAL: the imports, and the
/// clock setup that ends by starting the monotonic.
///
/// Keyed by HAL rather than by chip because that is where the code differs -
/// every STM32F4 starts the same way - and each is copied from a firmware that
/// has run on hardware. A chip whose HAL is missing here is refused by `new`,
/// and a test holds every built-in chip to having one.
struct Starter {
    hal: &'static str,
    imports: &'static str,
    clocks: &'static str,
}

const STARTERS: &[Starter] = &[
    Starter {
        hal: "stm32f4xx-hal",
        imports: "use stm32f4xx_hal::{prelude::*, rcc::Config};",
        clocks: "\
        // The internal oscillator, because it is on every board. Switch to the
        // crystal and raise `sysclk` once you know the board.
        let rcc = cx.device.RCC.freeze(Config::hsi());
        Mono::start(cx.core.SYST, rcc.clocks.sysclk().raw());",
    },
    Starter {
        hal: "stm32h7xx-hal",
        imports: "use stm32h7xx_hal::prelude::*;",
        clocks: "\
        // An H7 sets its core voltage before its clocks. The defaults run from
        // the internal oscillator, which is on every board.
        let pwr = cx.device.PWR.constrain();
        let pwrcfg = pwr.freeze();
        let rcc = cx.device.RCC.constrain();
        let ccdr = rcc.freeze(pwrcfg, &cx.device.SYSCFG);
        Mono::start(cx.core.SYST, ccdr.clocks.sys_ck().raw());",
    },
];

fn starter(backend: &Backend) -> Option<&'static Starter> {
    STARTERS
        .iter()
        .find(|starter| backend.platform_dependencies.contains_key(starter.hal))
}

/// A firmware that runs as soon as it is flashed: one task, selected from the
/// project's own task crate and declared the way every other one will be.
fn main_rs(backend: &Backend, starter: &Starter, task_crate: &str) -> String {
    let device = &backend.chip.device;
    let crate_path = task_crate.replace('-', "_");
    let Starter {
        imports, clocks, ..
    } = starter;
    format!(
        "//! One task from `tasks/{task_crate}`, selected and running.\n\
         //!\n\
         //! `ferroforge run` flashes this and shows the heartbeat over RTT. Grow it\n\
         //! from here: resources in `Shared` and `Local`, peripherals in `init`,\n\
         //! and one `#[task(from = ..)]` declaration per task instance.\n\n\
         #![no_std]\n\
         #![no_main]\n\n\
         use defmt_rtt as _;\n\
         use panic_probe as _;\n\n\
         ferroforge::app! {{\n\
         \x20   device = {device},\n\
         \x20   // Software tasks run from interrupts the application does not\n\
         \x20   // otherwise use. Pick another if this one becomes a peripheral's.\n\
         \x20   dispatchers = [SPI1],\n\n\
         \x20   use rtic_monotonics::systick::prelude::*;\n\n\
         \x20   // The clock tasks are handed. `heartbeat` needs 1 kHz.\n\
         \x20   systick_monotonic!(Mono, 1000);\n\n\
         \x20   use {crate_path}::heartbeat;\n\
         \x20   {imports}\n\n\
         \x20   #[shared]\n\
         \x20   struct Shared {{}}\n\n\
         \x20   #[local]\n\
         \x20   struct Local {{\n\
         \x20       heartbeat_count: u32,\n\
         \x20   }}\n\n\
         \x20   #[init]\n\
         \x20   fn init(cx: init::Context) -> (Shared, Local) {{\n\
         {clocks}\n\n\
         \x20       status::spawn().unwrap();\n\
         \x20       (Shared {{}}, Local {{ heartbeat_count: 0 }})\n\
         \x20   }}\n\n\
         \x20   // `heartbeat` is the definition; `status` is this firmware's instance\n\
         \x20   // of it, with its own resource and period.\n\
         \x20   #[task(\n\
         \x20       from = heartbeat,\n\
         \x20       priority = 1,\n\
         \x20       local = [count = heartbeat_count],\n\
         \x20       config = [period_ms: u32 = 1000],\n\
         \x20   )]\n\
         \x20   async fn status(cx: status::Context) -> !;\n\
         }}\n"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A chip `new` accepts but cannot start would only fail once someone
    /// tried it, so every built-in chip is held to having a starter here.
    #[test]
    fn every_built_in_chip_has_a_starting_firmware() {
        for chip in backend::known_chips() {
            let backend = Backend::for_chip(chip).expect("a built-in chip loads");
            assert!(
                starter(&backend).is_some(),
                "`{chip}` has no starter for any of its platform crates"
            );
        }
    }
}