1use crate::core::model::Model;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct Profile {
19 pub ip: String,
20 pub serial: String,
21 pub model: String,
23 #[serde(default = "default_mode")]
24 pub mode: String,
25 pub access_code: String,
27}
28
29fn default_mode() -> String {
30 "lan".to_string()
31}
32
33impl 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#[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#[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 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 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
105pub 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#[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 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 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
147pub 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
177pub 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 unsafe { std::env::set_var(&k, v) };
189 }
190 }
191}
192
193#[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
214pub 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"); assert_eq!(t.model, Model::X1Carbon); assert_eq!(t.serial, "0309FAxxxxxxxxx"); }
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 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")); assert_eq!(merged.serial.as_deref(), Some("env-serial")); }
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}