pub mod error;
#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use error::ConfigError;
pub const CONFIG_FILENAMES: &[&str] = &["fusible.yml", "fusible.yaml"];
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
pub struct FusibleConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub hardware: Option<HardwareConfig>,
#[serde(default)]
pub mcu: Option<McuConfig>,
#[serde(default)]
pub firmware: Option<FirmwareConfig>,
#[serde(default)]
pub mechanical: Option<MechanicalConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct HardwareConfig {
#[serde(default)]
pub entry: Option<PathBuf>,
#[serde(default, alias = "out-dir")]
pub out_dir: Option<PathBuf>,
#[serde(default, alias = "circuit-json")]
pub circuit_json: Option<PathBuf>,
#[serde(default)]
pub port: Option<u16>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct McuConfig {
#[serde(default)]
pub chip: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct FirmwareConfig {
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub framework: Option<String>,
#[serde(default, alias = "pins-rs")]
pub pins_rs: Option<PathBuf>,
#[serde(default, alias = "build-cmd")]
pub build_cmd: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
pub struct MechanicalConfig {
#[serde(default)]
pub format: Option<String>,
#[serde(default, alias = "out-dir")]
pub out_dir: Option<PathBuf>,
#[serde(default, alias = "wall-thickness")]
pub wall_thickness: Option<f64>,
#[serde(default)]
pub height: Option<f64>,
#[serde(default)]
pub cutouts: Option<bool>,
#[serde(default)]
pub ventilation: Option<String>,
#[serde(default)]
pub mounting: Option<bool>,
}
impl FusibleConfig {
pub fn find_and_load(start_dir: &Path) -> Result<Option<(PathBuf, Self)>, ConfigError> {
if start_dir.is_file() {
if let Some(found) = check_file_target(start_dir)? {
return Ok(Some(found));
}
}
let start_dir =
std::path::absolute(start_dir).map_err(|err| ConfigError::io(start_dir, err))?;
let dir = if start_dir.is_file() {
start_dir.parent().unwrap_or(&start_dir)
} else {
&start_dir
};
for ancestor in dir.ancestors() {
if let Some(found) = find_in_dir(ancestor)? {
return Ok(Some(found));
}
}
Ok(None)
}
fn rebased(mut self, dir: &Path) -> Self {
let rebase = |p: &mut Option<PathBuf>| {
if let Some(path) = p.as_mut().filter(|path| path.is_relative()) {
*path = dir.join(&*path);
}
};
if let Some(hw) = self.hardware.as_mut() {
rebase(&mut hw.entry);
rebase(&mut hw.out_dir);
rebase(&mut hw.circuit_json);
}
if let Some(fw) = self.firmware.as_mut() {
rebase(&mut fw.path);
rebase(&mut fw.pins_rs);
}
if let Some(mech) = self.mechanical.as_mut() {
rebase(&mut mech.out_dir);
}
self
}
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path).map_err(|err| ConfigError::io(path, err))?;
let config = Self::from_str_named(&content, &path.display().to_string())?;
Ok(config.rebased(path.parent().unwrap_or(Path::new(""))))
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(content: &str) -> Result<Self, ConfigError> {
Self::from_str_named(content, "fusible.yml")
}
pub fn from_str_named(content: &str, file_name: &str) -> Result<Self, ConfigError> {
if content.trim().is_empty() {
return Ok(Self::default());
}
let config: Option<Self> = serde_yaml::from_str(content)
.map_err(|err| ConfigError::from_yaml(err, content, file_name))?;
Ok(config.unwrap_or_default())
}
}
impl std::str::FromStr for FusibleConfig {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_str_named(s, "fusible.yml")
}
}
impl FusibleConfig {
pub fn default_manifest() -> &'static str {
r#"# Fusible project manifest
# Docs: https://github.com/KonoLabs/Fusible
name: sensor-node
hardware:
# Top-level board entry point (.tsx or circuit.json)
entry: hardware/index.tsx
# Output directory for circuit artifacts
out_dir: dist
# Dev server port for interactive previewer
port: 3020
mcu:
# Target silicon identifier (e.g., stm32g431, esp32s3, rp2040)
chip: stm32g431
firmware:
# Directory containing firmware crate or code
path: firmware
# Firmware framework: embassy | cortex-m | esp-idf
framework: embassy
# Destination for auto-generated pin bindings
pins_rs: firmware/src/pins.rs
# Optional command to build firmware
build_cmd: cargo build
# mechanical:
# format: step # step | stl | glb | brep | all
# out_dir: dist/mechanical
# wall_thickness: 2.0
# height: 20.0 # derived from the board stack when omitted
# cutouts: true # notch walls where components reach the board edge
# ventilation: none # none | grid | slots
# mounting: true # screw bosses under each pcb_hole
"#
}
}
fn check_file_target(path: &Path) -> Result<Option<(PathBuf, FusibleConfig)>, ConfigError> {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if CONFIG_FILENAMES.contains(&name) {
let config = FusibleConfig::from_file(path)?;
return Ok(Some((path.to_path_buf(), config)));
}
}
Ok(None)
}
fn find_in_dir(dir: &Path) -> Result<Option<(PathBuf, FusibleConfig)>, ConfigError> {
for filename in CONFIG_FILENAMES {
let candidate = if dir.as_os_str().is_empty() {
PathBuf::from(filename)
} else {
dir.join(filename)
};
if candidate.is_file() {
let config = FusibleConfig::from_file(&candidate)?;
return Ok(Some((candidate, config)));
}
}
Ok(None)
}