Skip to main content

bambu_rs/
config.rs

1//! Printer connection profiles and credential resolution.
2//!
3//! A [`Config`] holds named [`Profile`]s on disk; [`resolve`] merges a profile
4//! with per-invocation [`Overrides`] (flags / `BAMBU_*` env) — overrides win —
5//! into a [`ResolvedTarget`] ready to connect with. The LAN access code is a
6//! secret: it is stored 0600, never logged, and redacted from `Debug`.
7//!
8//! (An OS-keyring backend is a planned enhancement; this is the 0600-file
9//! fallback the plan calls for.)
10
11use crate::core::model::Model;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16/// A stored printer profile.
17#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct Profile {
19    pub ip: String,
20    pub serial: String,
21    /// Canonical model name (see [`Model::from_config_str`]).
22    pub model: String,
23    #[serde(default = "default_mode")]
24    pub mode: String,
25    /// LAN access code (the 8-digit secret). Redacted from `Debug`.
26    pub access_code: String,
27}
28
29fn default_mode() -> String {
30    "lan".to_string()
31}
32
33// Manual Debug so the access code never leaks into logs / error output.
34impl std::fmt::Debug for Profile {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("Profile")
37            .field("ip", &self.ip)
38            .field("serial", &self.serial)
39            .field("model", &self.model)
40            .field("mode", &self.mode)
41            .field("access_code", &"<redacted>")
42            .finish()
43    }
44}
45
46/// The on-disk configuration: named profiles plus an optional default.
47#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
48pub struct Config {
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub default_printer: Option<String>,
51    #[serde(default)]
52    pub printers: BTreeMap<String, Profile>,
53}
54
55/// Errors from config handling and target resolution.
56#[derive(Debug, thiserror::Error)]
57pub enum ConfigError {
58    #[error("missing required connection field: {0}")]
59    MissingField(&'static str),
60    #[error("no such printer profile: {0}")]
61    UnknownProfile(String),
62    #[error("config i/o error: {0}")]
63    Io(#[from] std::io::Error),
64    #[error("config parse error: {0}")]
65    Parse(#[from] toml::de::Error),
66    #[error("config serialize error: {0}")]
67    Serialize(#[from] toml::ser::Error),
68}
69
70impl Config {
71    /// Load from `path`, or return an empty config if the file doesn't exist.
72    pub fn load_or_default(path: &Path) -> Result<Config, ConfigError> {
73        match std::fs::read_to_string(path) {
74            Ok(text) => Ok(toml::from_str(&text)?),
75            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
76            Err(e) => Err(e.into()),
77        }
78    }
79
80    /// Write to `path` (creating parent dirs) with owner-only (0600) permissions.
81    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
82        if let Some(dir) = path.parent() {
83            std::fs::create_dir_all(dir)?;
84        }
85        std::fs::write(path, toml::to_string_pretty(self)?)?;
86        set_owner_only(path)?;
87        Ok(())
88    }
89
90    pub fn profile(&self, name: &str) -> Option<&Profile> {
91        self.printers.get(name)
92    }
93}
94
95#[cfg(unix)]
96fn set_owner_only(path: &Path) -> std::io::Result<()> {
97    use std::os::unix::fs::PermissionsExt;
98    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
99}
100#[cfg(not(unix))]
101fn set_owner_only(_path: &Path) -> std::io::Result<()> {
102    Ok(())
103}
104
105/// The default config path (`$XDG_CONFIG_HOME/bambu-rs/config.toml`, else
106/// `~/.config/bambu-rs/config.toml`).
107pub fn default_config_path() -> Option<PathBuf> {
108    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
109        return Some(PathBuf::from(xdg).join("bambu-rs/config.toml"));
110    }
111    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/bambu-rs/config.toml"))
112}
113
114/// Per-invocation overrides (from flags and/or env). Higher precedence than a
115/// stored profile.
116#[derive(Clone, Default)]
117pub struct Overrides {
118    pub ip: Option<String>,
119    pub serial: Option<String>,
120    pub access_code: Option<String>,
121    pub model: Option<String>,
122}
123
124impl Overrides {
125    /// Read `BAMBU_IP` / `BAMBU_SERIAL` / `BAMBU_ACCESS_CODE` / `BAMBU_MODEL`.
126    pub fn from_env() -> Self {
127        let v = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
128        Overrides {
129            ip: v("BAMBU_IP"),
130            serial: v("BAMBU_SERIAL"),
131            access_code: v("BAMBU_ACCESS_CODE"),
132            model: v("BAMBU_MODEL"),
133        }
134    }
135
136    /// Overlay `self` over `lower`, `self` winning. Used to apply flags over env.
137    pub fn over(self, lower: Overrides) -> Overrides {
138        Overrides {
139            ip: self.ip.or(lower.ip),
140            serial: self.serial.or(lower.serial),
141            access_code: self.access_code.or(lower.access_code),
142            model: self.model.or(lower.model),
143        }
144    }
145}
146
147/// Parse the `BAMBU_*` assignments from `.env`-style content. Only `BAMBU_`-
148/// prefixed keys are returned (so an unrelated `.env` can't inject surprising
149/// config); an optional `export ` prefix and matching surrounding quotes are
150/// stripped. Pure (no I/O) so it is unit-testable.
151pub fn parse_dotenv(content: &str) -> Vec<(String, String)> {
152    let mut out = Vec::new();
153    for line in content.lines() {
154        let line = line.trim();
155        if line.is_empty() || line.starts_with('#') {
156            continue;
157        }
158        let line = line.strip_prefix("export ").unwrap_or(line);
159        let Some((k, v)) = line.split_once('=') else {
160            continue;
161        };
162        let k = k.trim();
163        if !k.starts_with("BAMBU_") {
164            continue;
165        }
166        let v = v.trim();
167        let v = v
168            .strip_prefix('"')
169            .and_then(|s| s.strip_suffix('"'))
170            .or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
171            .unwrap_or(v);
172        out.push((k.to_string(), v.to_string()));
173    }
174    out
175}
176
177/// Best-effort: load `BAMBU_*` keys from `./.env` into the process environment,
178/// **without** overriding variables already set (so the precedence stays
179/// flags > real env > `.env` > config). A missing/unreadable file is ignored.
180/// The access code is never logged.
181pub fn load_dotenv() {
182    let Ok(content) = std::fs::read_to_string(".env") else {
183        return;
184    };
185    for (k, v) in parse_dotenv(&content) {
186        if std::env::var_os(&k).is_none() {
187            // Safe: called once at startup, before any threads are spawned.
188            unsafe { std::env::set_var(&k, v) };
189        }
190    }
191}
192
193/// A fully-resolved connection target. Holds the access-code secret (redacted
194/// from `Debug`).
195#[derive(Clone, PartialEq, Eq)]
196pub struct ResolvedTarget {
197    pub ip: String,
198    pub serial: String,
199    pub access_code: String,
200    pub model: Model,
201}
202
203impl std::fmt::Debug for ResolvedTarget {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.debug_struct("ResolvedTarget")
206            .field("ip", &self.ip)
207            .field("serial", &self.serial)
208            .field("model", &self.model)
209            .field("access_code", &"<redacted>")
210            .finish()
211    }
212}
213
214/// Resolve a connection target from an optional stored profile plus overrides.
215/// **Precedence: overrides win over the profile.** Every required field
216/// (ip, serial, access_code, model) must come from one or the other.
217pub fn resolve(
218    profile: Option<&Profile>,
219    overrides: &Overrides,
220) -> Result<ResolvedTarget, ConfigError> {
221    let pick = |ov: &Option<String>, field: fn(&Profile) -> &str, name: &'static str| {
222        ov.clone()
223            .or_else(|| profile.map(|p| field(p).to_string()))
224            .filter(|s| !s.is_empty())
225            .ok_or(ConfigError::MissingField(name))
226    };
227    let ip = pick(&overrides.ip, |p| &p.ip, "ip")?;
228    let serial = pick(&overrides.serial, |p| &p.serial, "serial")?;
229    let access_code = pick(&overrides.access_code, |p| &p.access_code, "access_code")?;
230    let model_str = pick(&overrides.model, |p| &p.model, "model")?;
231    Ok(ResolvedTarget {
232        ip,
233        serial,
234        access_code,
235        model: Model::from_config_str(&model_str),
236    })
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parse_dotenv_reads_bambu_keys_only_with_quotes_and_export() {
245        let content = "\
246# a comment
247BAMBU_IP=192.0.2.10
248export BAMBU_SERIAL=0309ABC
249BAMBU_ACCESS_CODE=\"12345678\"
250BAMBU_MODEL='a1mini'
251
252PATH=/should/not/leak
253NOT_BAMBU=ignored
254malformed line without equals
255";
256        let got = parse_dotenv(content);
257        assert_eq!(
258            got,
259            vec![
260                ("BAMBU_IP".to_string(), "192.0.2.10".to_string()),
261                ("BAMBU_SERIAL".to_string(), "0309ABC".to_string()),
262                ("BAMBU_ACCESS_CODE".to_string(), "12345678".to_string()),
263                ("BAMBU_MODEL".to_string(), "a1mini".to_string()),
264            ]
265        );
266    }
267
268    fn sample_profile() -> Profile {
269        Profile {
270            ip: "192.0.2.10".into(),
271            serial: "0309FAxxxxxxxxx".into(),
272            model: "a1mini".into(),
273            mode: "lan".into(),
274            access_code: "00000000".into(),
275        }
276    }
277
278    #[test]
279    fn resolve_uses_profile_when_no_overrides() {
280        let p = sample_profile();
281        let t = resolve(Some(&p), &Overrides::default()).unwrap();
282        assert_eq!(t.ip, "192.0.2.10");
283        assert_eq!(t.model, Model::A1Mini);
284        assert_eq!(t.access_code, "00000000");
285    }
286
287    #[test]
288    fn overrides_win_over_profile() {
289        let p = sample_profile();
290        let ov = Overrides {
291            ip: Some("198.51.100.9".into()),
292            model: Some("x1c".into()),
293            ..Default::default()
294        };
295        let t = resolve(Some(&p), &ov).unwrap();
296        assert_eq!(t.ip, "198.51.100.9"); // override
297        assert_eq!(t.model, Model::X1Carbon); // override
298        assert_eq!(t.serial, "0309FAxxxxxxxxx"); // from profile
299    }
300
301    #[test]
302    fn missing_field_is_an_error() {
303        let err = resolve(None, &Overrides::default()).unwrap_err();
304        assert!(matches!(err, ConfigError::MissingField("ip")));
305        // Even partial overrides leave required fields missing.
306        let ov = Overrides {
307            ip: Some("198.51.100.9".into()),
308            ..Default::default()
309        };
310        assert!(matches!(
311            resolve(None, &ov).unwrap_err(),
312            ConfigError::MissingField("serial")
313        ));
314    }
315
316    #[test]
317    fn overrides_over_applies_flags_above_env() {
318        let env = Overrides {
319            ip: Some("env-ip".into()),
320            serial: Some("env-serial".into()),
321            ..Default::default()
322        };
323        let flags = Overrides {
324            ip: Some("flag-ip".into()),
325            ..Default::default()
326        };
327        let merged = flags.over(env);
328        assert_eq!(merged.ip.as_deref(), Some("flag-ip")); // flag wins
329        assert_eq!(merged.serial.as_deref(), Some("env-serial")); // falls back to env
330    }
331
332    #[test]
333    fn debug_redacts_the_access_code() {
334        let dbg = format!("{:?}", sample_profile());
335        assert!(dbg.contains("<redacted>"));
336        assert!(!dbg.contains("00000000"));
337        let t = resolve(Some(&sample_profile()), &Overrides::default()).unwrap();
338        assert!(!format!("{t:?}").contains("00000000"));
339    }
340
341    fn config_with_one(name: &str, default: bool) -> Config {
342        let mut printers = BTreeMap::new();
343        printers.insert(name.to_string(), sample_profile());
344        Config {
345            default_printer: default.then(|| name.to_string()),
346            printers,
347        }
348    }
349
350    #[test]
351    fn config_toml_round_trips() {
352        let cfg = config_with_one("a1", true);
353        let text = toml::to_string_pretty(&cfg).unwrap();
354        let back: Config = toml::from_str(&text).unwrap();
355        assert_eq!(cfg, back);
356    }
357
358    #[test]
359    fn save_then_load_round_trips_and_is_owner_only() {
360        let cfg = config_with_one("a1", false);
361        let path = std::env::temp_dir().join(format!(
362            "bambu-rs-cfg-test-{}-{}.toml",
363            std::process::id(),
364            "save_load"
365        ));
366        cfg.save(&path).unwrap();
367        let loaded = Config::load_or_default(&path).unwrap();
368        assert_eq!(cfg, loaded);
369        #[cfg(unix)]
370        {
371            use std::os::unix::fs::PermissionsExt;
372            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
373            assert_eq!(mode & 0o777, 0o600);
374        }
375        let _ = std::fs::remove_file(&path);
376    }
377
378    #[test]
379    fn load_or_default_is_empty_when_absent() {
380        let path = std::env::temp_dir().join("bambu-rs-definitely-not-here-9z.toml");
381        let _ = std::fs::remove_file(&path);
382        assert_eq!(Config::load_or_default(&path).unwrap(), Config::default());
383    }
384}