1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fmt, fs, io,
6 path::{Path, PathBuf},
7 process::Command,
8};
9
10use serde::Deserialize;
11
12pub const SERVICE_NAME: &str = "bindport";
13pub const DEFAULT_PORT_RANGE_START: u16 = 29_000;
14pub const DEFAULT_PORT_RANGE_END: u16 = 29_999;
15pub const DEFAULT_PORT_RANGE: PortRange = PortRange {
16 start: DEFAULT_PORT_RANGE_START,
17 end: DEFAULT_PORT_RANGE_END,
18};
19pub const DEFAULT_SKIP_PORTS: &[u16] = &[
20 29_000, 29_070, 29_118, 29_167, 29_168, 29_169, 29_900, 29_901, 29_920, 29_999,
21];
22pub const DEFAULT_OUTPUT_TARGET_HOST: &str = "127.0.0.1";
23pub const DEFAULT_OUTPUT_TARGET_SCHEME: &str = "http";
24pub const DEFAULT_OUTPUT_AUTO_RENDER: bool = true;
25pub const DEFAULT_OUTPUT_DEBOUNCE_MS: u64 = 250;
26pub const CONFIG_FILENAMES: &[&str] = &[".bindport.toml", ".bindport.json", ".bindport.yaml"];
27pub const LOCAL_CONFIG_FILENAMES: &[&str] = &[
28 ".bindport.local.toml",
29 ".bindport.local.json",
30 ".bindport.local.yaml",
31 ".bindport.local.yml",
32 "bindport.local.toml",
33 "bindport.local.json",
34 "bindport.local.yaml",
35 "bindport.local.yml",
36];
37pub const FALLBACK_CONFIG_FILE: &str = "config.toml";
38pub const APPLIED_CONFIG_KEYS: &[&str] = &[
39 "project",
40 "service",
41 "default_range",
42 "skip_ports",
43 "services",
44 "dashboard",
45 "output_defaults",
46 "outputs",
47];
48pub const BINDPORT_PROJECT_ENV: &str = "BINDPORT_PROJECT";
49pub const BINDPORT_SERVICE_ENV: &str = "BINDPORT_SERVICE";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct PortRange {
53 pub start: u16,
54 pub end: u16,
55}
56
57impl PortRange {
58 pub const fn contains(self, port: u16) -> bool {
59 self.start <= port && port <= self.end
60 }
61
62 pub const fn len(self) -> u32 {
63 if self.is_empty() {
64 0
65 } else {
66 self.end as u32 - self.start as u32 + 1
67 }
68 }
69
70 pub const fn is_empty(self) -> bool {
71 self.start > self.end
72 }
73}
74
75pub fn is_default_skip_port(port: u16) -> bool {
76 DEFAULT_SKIP_PORTS.contains(&port)
77}
78
79#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
80#[serde(default)]
81pub struct BindPortConfig {
82 pub project: Option<String>,
83 pub service: Option<String>,
84 pub default_range: Option<String>,
85 pub skip_ports: Option<Vec<u16>>,
86 pub services: Option<Vec<ServiceConfig>>,
87 pub dashboard: Option<DashboardConfig>,
88 pub output_defaults: Option<OutputDefaultsConfig>,
89 pub outputs: Option<Vec<OutputConfig>>,
90}
91
92impl BindPortConfig {
93 pub fn configured_service_name(&self) -> Option<&str> {
94 self.service.as_deref().or(match self.services.as_deref() {
95 Some([service]) => service.name.as_deref(),
96 _ => None,
97 })
98 }
99
100 pub fn service_config(&self, service_name: &str) -> Option<&ServiceConfig> {
101 self.services.as_deref()?.iter().find(|service| {
102 service
103 .name
104 .as_deref()
105 .is_some_and(|name| name == service_name)
106 })
107 }
108
109 pub fn output_config(&self, output_name: &str) -> Option<&OutputConfig> {
110 self.outputs.as_deref()?.iter().find(|output| {
111 output
112 .name
113 .as_deref()
114 .is_some_and(|name| name == output_name)
115 })
116 }
117
118 pub fn effective_outputs(&self) -> Result<Vec<EffectiveOutputConfig>, OutputConfigError> {
119 let Some(outputs) = self.outputs.as_deref() else {
120 return Ok(Vec::new());
121 };
122 let defaults = self.output_defaults.as_ref();
123 let mut seen_names = BTreeSet::new();
124 let mut effective = Vec::new();
125
126 for (index, output) in outputs.iter().enumerate() {
127 let name = output
128 .name
129 .as_deref()
130 .map(str::trim)
131 .filter(|name| !name.is_empty())
132 .ok_or(OutputConfigError::MissingName { index })?;
133 let name = name.to_string();
134
135 if !seen_names.insert(name.clone()) {
136 return Err(OutputConfigError::DuplicateName { name });
137 }
138
139 let enabled = output.enabled.unwrap_or(true);
140 if !enabled {
141 continue;
142 }
143
144 let template = output
145 .template
146 .as_ref()
147 .filter(|template| !template.trim().is_empty())
148 .cloned()
149 .ok_or_else(|| OutputConfigError::MissingTemplate { name: name.clone() })?;
150 let target = output
151 .target
152 .as_ref()
153 .filter(|target| !target.trim().is_empty())
154 .cloned()
155 .ok_or_else(|| OutputConfigError::MissingTarget { name: name.clone() })?;
156
157 effective.push(EffectiveOutputConfig {
158 name,
159 template,
160 root: output
161 .root
162 .clone()
163 .or_else(|| defaults.and_then(|defaults| defaults.root.clone())),
164 target,
165 target_host: output
166 .target_host
167 .clone()
168 .or_else(|| defaults.and_then(|defaults| defaults.target_host.clone()))
169 .unwrap_or_else(|| DEFAULT_OUTPUT_TARGET_HOST.to_string()),
170 target_scheme: output
171 .target_scheme
172 .clone()
173 .or_else(|| defaults.and_then(|defaults| defaults.target_scheme.clone()))
174 .unwrap_or_else(|| DEFAULT_OUTPUT_TARGET_SCHEME.to_string()),
175 auto_render: output
176 .auto_render
177 .or_else(|| defaults.and_then(|defaults| defaults.auto_render))
178 .unwrap_or(DEFAULT_OUTPUT_AUTO_RENDER),
179 delete_on: output
180 .delete_on
181 .clone()
182 .or_else(|| defaults.and_then(|defaults| defaults.delete_on.clone()))
183 .unwrap_or_else(|| vec![OutputDeleteState::Removed]),
184 on_failure: output
185 .on_failure
186 .clone()
187 .or_else(|| defaults.and_then(|defaults| defaults.on_failure.clone()))
188 .unwrap_or(OutputFailurePolicy::Warn),
189 debounce_ms: output
190 .debounce_ms
191 .or_else(|| defaults.and_then(|defaults| defaults.debounce_ms))
192 .unwrap_or(DEFAULT_OUTPUT_DEBOUNCE_MS),
193 vars: output.vars.clone().unwrap_or_default(),
194 });
195 }
196
197 Ok(effective)
198 }
199
200 pub fn merge_local_override(&mut self, local: BindPortConfig) {
201 override_option(&mut self.project, local.project);
202 override_option(&mut self.service, local.service);
203 override_option(&mut self.default_range, local.default_range);
204 override_option(&mut self.skip_ports, local.skip_ports);
205 override_option(&mut self.services, local.services);
206 merge_option_with(&mut self.dashboard, local.dashboard, DashboardConfig::merge);
207 merge_option_with(
208 &mut self.output_defaults,
209 local.output_defaults,
210 OutputDefaultsConfig::merge,
211 );
212 merge_outputs(&mut self.outputs, local.outputs);
213 }
214}
215
216#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
217#[serde(default)]
218pub struct ServiceConfig {
219 pub name: Option<String>,
220 pub command: Option<String>,
221 pub env: Option<BTreeMap<String, String>>,
222 pub hostname: Option<String>,
223 pub route_url: Option<String>,
224}
225
226#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
227#[serde(default)]
228pub struct DashboardConfig {
229 pub host: Option<String>,
230 pub port: Option<u16>,
231 pub register_service: Option<bool>,
232 pub allowed_hosts: Option<Vec<String>>,
233 pub auth: Option<DashboardAuthConfig>,
234}
235
236#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
237#[serde(default)]
238pub struct DashboardAuthConfig {
239 pub required: Option<bool>,
240 pub token: Option<String>,
241 pub token_env: Option<String>,
242}
243
244impl DashboardConfig {
245 fn merge(&mut self, local: Self) {
246 override_option(&mut self.host, local.host);
247 override_option(&mut self.port, local.port);
248 override_option(&mut self.register_service, local.register_service);
249 override_option(&mut self.allowed_hosts, local.allowed_hosts);
250 merge_option_with(&mut self.auth, local.auth, DashboardAuthConfig::merge);
251 }
252}
253
254impl DashboardAuthConfig {
255 fn merge(&mut self, local: Self) {
256 override_option(&mut self.required, local.required);
257 override_option(&mut self.token, local.token);
258 override_option(&mut self.token_env, local.token_env);
259 }
260}
261
262#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
263#[serde(default)]
264pub struct OutputDefaultsConfig {
265 pub root: Option<String>,
266 pub target_host: Option<String>,
267 pub target_scheme: Option<String>,
268 pub auto_render: Option<bool>,
269 pub delete_on: Option<Vec<OutputDeleteState>>,
270 pub on_failure: Option<OutputFailurePolicy>,
271 pub debounce_ms: Option<u64>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct EffectiveOutputConfig {
276 pub name: String,
277 pub template: String,
278 pub root: Option<String>,
279 pub target: String,
280 pub target_host: String,
281 pub target_scheme: String,
282 pub auto_render: bool,
283 pub delete_on: Vec<OutputDeleteState>,
284 pub on_failure: OutputFailurePolicy,
285 pub debounce_ms: u64,
286 pub vars: BTreeMap<String, serde_json::Value>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum OutputConfigError {
291 MissingName { index: usize },
292 DuplicateName { name: String },
293 MissingTemplate { name: String },
294 MissingTarget { name: String },
295}
296
297impl fmt::Display for OutputConfigError {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 match self {
300 Self::MissingName { index } => {
301 write!(f, "outputs[{index}] is missing required `name`")
302 }
303 Self::DuplicateName { name } => {
304 write!(f, "output `{name}` is defined more than once")
305 }
306 Self::MissingTemplate { name } => {
307 write!(f, "output `{name}` is missing required `template`")
308 }
309 Self::MissingTarget { name } => {
310 write!(f, "output `{name}` is missing required `target`")
311 }
312 }
313 }
314}
315
316impl std::error::Error for OutputConfigError {}
317
318impl OutputDefaultsConfig {
319 fn merge(&mut self, local: Self) {
320 override_option(&mut self.root, local.root);
321 override_option(&mut self.target_host, local.target_host);
322 override_option(&mut self.target_scheme, local.target_scheme);
323 override_option(&mut self.auto_render, local.auto_render);
324 override_option(&mut self.delete_on, local.delete_on);
325 override_option(&mut self.on_failure, local.on_failure);
326 override_option(&mut self.debounce_ms, local.debounce_ms);
327 }
328}
329
330#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
331#[serde(rename_all = "snake_case")]
332pub enum OutputDeleteState {
333 Stopped,
334 Stale,
335 Removed,
336}
337
338impl OutputDeleteState {
339 pub const fn as_str(&self) -> &'static str {
340 match self {
341 Self::Stopped => "stopped",
342 Self::Stale => "stale",
343 Self::Removed => "removed",
344 }
345 }
346}
347
348#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
349#[serde(rename_all = "snake_case")]
350pub enum OutputFailurePolicy {
351 Warn,
352 Block,
353}
354
355impl OutputFailurePolicy {
356 pub const fn as_str(&self) -> &'static str {
357 match self {
358 Self::Warn => "warn",
359 Self::Block => "block",
360 }
361 }
362}
363
364#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
365#[serde(default)]
366pub struct OutputConfig {
367 pub enabled: Option<bool>,
368 pub name: Option<String>,
369 pub template: Option<String>,
370 pub root: Option<String>,
371 pub target: Option<String>,
372 pub target_host: Option<String>,
373 pub target_scheme: Option<String>,
374 pub auto_render: Option<bool>,
375 pub delete_on: Option<Vec<OutputDeleteState>>,
376 pub on_failure: Option<OutputFailurePolicy>,
377 pub debounce_ms: Option<u64>,
378 pub vars: Option<BTreeMap<String, serde_json::Value>>,
379}
380
381impl OutputConfig {
382 fn merge(&mut self, local: Self) {
383 override_option(&mut self.enabled, local.enabled);
384 override_option(&mut self.template, local.template);
385 override_option(&mut self.root, local.root);
386 override_option(&mut self.target, local.target);
387 override_option(&mut self.target_host, local.target_host);
388 override_option(&mut self.target_scheme, local.target_scheme);
389 override_option(&mut self.auto_render, local.auto_render);
390 override_option(&mut self.delete_on, local.delete_on);
391 override_option(&mut self.on_failure, local.on_failure);
392 override_option(&mut self.debounce_ms, local.debounce_ms);
393 merge_map_option(&mut self.vars, local.vars);
394 }
395}
396
397fn override_option<T>(base: &mut Option<T>, local: Option<T>) {
398 if local.is_some() {
399 *base = local;
400 }
401}
402
403fn merge_option_with<T>(base: &mut Option<T>, local: Option<T>, merge: impl FnOnce(&mut T, T)) {
404 match (base.as_mut(), local) {
405 (Some(base), Some(local)) => merge(base, local),
406 (None, Some(local)) => *base = Some(local),
407 (_, None) => {}
408 }
409}
410
411fn merge_map_option<T>(base: &mut Option<BTreeMap<String, T>>, local: Option<BTreeMap<String, T>>) {
412 let Some(local) = local else {
413 return;
414 };
415
416 if let Some(base) = base {
417 base.extend(local);
418 } else {
419 *base = Some(local);
420 }
421}
422
423fn merge_outputs(base: &mut Option<Vec<OutputConfig>>, local: Option<Vec<OutputConfig>>) {
424 let Some(local_outputs) = local else {
425 return;
426 };
427
428 let Some(base_outputs) = base else {
429 *base = Some(local_outputs);
430 return;
431 };
432
433 for local_output in local_outputs {
434 let Some(local_name) = local_output.name.as_deref() else {
435 base_outputs.push(local_output);
436 continue;
437 };
438
439 if let Some(base_output) = base_outputs
440 .iter_mut()
441 .find(|output| output.name.as_deref() == Some(local_name))
442 {
443 base_output.merge(local_output);
444 } else {
445 base_outputs.push(local_output);
446 }
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub struct GitIdentity {
452 pub worktree_path: PathBuf,
453 pub worktree_hash: String,
454 pub git_common_dir: PathBuf,
455 pub branch: String,
456 pub branch_label: String,
457 pub commit: String,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct ServiceIdentity {
462 pub project: String,
463 pub service: String,
464 pub git: Option<GitIdentity>,
465 pub identity_key: String,
466}
467
468impl ServiceIdentity {
469 pub fn port_scan_start(&self, range: PortRange) -> Option<u16> {
470 stable_port_scan_start(&self.identity_key, range)
471 }
472}
473
474#[derive(Debug, Clone, Copy)]
475pub struct IdentitySources<'a> {
476 pub cwd: &'a Path,
477 pub command: &'a [String],
478 pub cli_project: Option<&'a str>,
479 pub cli_service: Option<&'a str>,
480 pub env_project: Option<&'a str>,
481 pub env_service: Option<&'a str>,
482 pub config_project: Option<&'a str>,
483 pub config_service: Option<&'a str>,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub enum ConfigFormat {
488 Toml,
489 Json,
490 Yaml,
491}
492
493impl ConfigFormat {
494 pub fn from_path(path: &Path) -> Option<Self> {
495 match path.extension().and_then(|extension| extension.to_str()) {
496 Some("toml") => Some(Self::Toml),
497 Some("json") => Some(Self::Json),
498 Some("yaml") => Some(Self::Yaml),
499 Some("yml") => Some(Self::Yaml),
500 _ => None,
501 }
502 }
503
504 pub const fn as_str(self) -> &'static str {
505 match self {
506 Self::Toml => "toml",
507 Self::Json => "json",
508 Self::Yaml => "yaml",
509 }
510 }
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum ConfigSource {
515 Project,
516 Fallback,
517}
518
519impl ConfigSource {
520 pub const fn as_str(self) -> &'static str {
521 match self {
522 Self::Project => "project",
523 Self::Fallback => "fallback",
524 }
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub struct LoadedConfig {
530 pub path: PathBuf,
531 pub format: ConfigFormat,
532 pub source: ConfigSource,
533 pub local_override: Option<LoadedLocalConfig>,
534 pub config: BindPortConfig,
535 pub unknown_keys: Vec<String>,
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct LoadedLocalConfig {
540 pub path: PathBuf,
541 pub format: ConfigFormat,
542 pub unknown_keys: Vec<String>,
543}
544
545impl LoadedConfig {
546 pub fn port_range(&self) -> Result<PortRange, ConfigError> {
547 self.config
548 .default_range
549 .as_deref()
550 .map(parse_port_range)
551 .transpose()
552 .map_err(|source| ConfigError::InvalidPortRange {
553 path: self.path.clone(),
554 source,
555 })
556 .map(|range| range.unwrap_or(DEFAULT_PORT_RANGE))
557 }
558
559 pub fn skip_ports(&self) -> Vec<u16> {
560 self.config
561 .skip_ports
562 .clone()
563 .unwrap_or_else(|| DEFAULT_SKIP_PORTS.to_vec())
564 }
565}
566
567#[derive(Debug)]
568pub enum ConfigError {
569 Read {
570 path: PathBuf,
571 source: io::Error,
572 },
573 UnknownFormat {
574 path: PathBuf,
575 },
576 Parse {
577 path: PathBuf,
578 format: ConfigFormat,
579 source: String,
580 },
581 InvalidPortRange {
582 path: PathBuf,
583 source: PortRangeParseError,
584 },
585}
586
587impl fmt::Display for ConfigError {
588 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
589 match self {
590 Self::Read { path, source } => {
591 write!(f, "failed to read config `{}`: {source}", path.display())
592 }
593 Self::UnknownFormat { path } => {
594 write!(f, "unsupported config format `{}`", path.display())
595 }
596 Self::Parse {
597 path,
598 format,
599 source,
600 } => {
601 write!(
602 f,
603 "failed to parse {} config `{}`: {source}",
604 format.as_str(),
605 path.display()
606 )
607 }
608 Self::InvalidPortRange { path, source } => {
609 write!(
610 f,
611 "invalid default_range in config `{}`: {source}",
612 path.display()
613 )
614 }
615 }
616 }
617}
618
619impl std::error::Error for ConfigError {
620 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
621 match self {
622 Self::Read { source, .. } => Some(source),
623 Self::InvalidPortRange { source, .. } => Some(source),
624 Self::UnknownFormat { .. } | Self::Parse { .. } => None,
625 }
626 }
627}
628
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub enum PortRangeParseError {
631 MissingSeparator,
632 InvalidStart(String),
633 InvalidEnd(String),
634 Empty(PortRange),
635}
636
637impl fmt::Display for PortRangeParseError {
638 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639 match self {
640 Self::MissingSeparator => write!(f, "expected START-END"),
641 Self::InvalidStart(value) => write!(f, "invalid range start `{value}`"),
642 Self::InvalidEnd(value) => write!(f, "invalid range end `{value}`"),
643 Self::Empty(range) => write!(
644 f,
645 "range start {} must be less than or equal to end {}",
646 range.start, range.end
647 ),
648 }
649 }
650}
651
652impl std::error::Error for PortRangeParseError {}
653
654pub fn discover_config(
655 start: &Path,
656 fallback_path: Option<&Path>,
657) -> Result<Option<LoadedConfig>, ConfigError> {
658 for directory in start.ancestors() {
659 for filename in CONFIG_FILENAMES {
660 let path = directory.join(filename);
661
662 if path.is_file() {
663 return load_config(path, ConfigSource::Project)
664 .and_then(load_project_local_override)
665 .map(Some);
666 }
667 }
668 }
669
670 if let Some(path) = fallback_path.filter(|path| path.is_file()) {
671 return load_config(path, ConfigSource::Fallback).map(Some);
672 }
673
674 Ok(None)
675}
676
677fn load_project_local_override(mut loaded: LoadedConfig) -> Result<LoadedConfig, ConfigError> {
678 if loaded.source != ConfigSource::Project {
679 return Ok(loaded);
680 }
681
682 let Some(directory) = loaded.path.parent() else {
683 return Ok(loaded);
684 };
685
686 for filename in LOCAL_CONFIG_FILENAMES {
687 let path = directory.join(filename);
688
689 if path.is_file() {
690 let local = load_config(path, ConfigSource::Project)?;
691 let LoadedConfig {
692 path,
693 format,
694 config,
695 unknown_keys,
696 ..
697 } = local;
698 loaded.config.merge_local_override(config);
699 loaded.unknown_keys.extend(unknown_keys.clone());
700 loaded.unknown_keys.sort();
701 loaded.unknown_keys.dedup();
702 loaded.local_override = Some(LoadedLocalConfig {
703 path,
704 format,
705 unknown_keys,
706 });
707 return Ok(loaded);
708 }
709 }
710
711 Ok(loaded)
712}
713
714pub fn load_config(
715 path: impl Into<PathBuf>,
716 source: ConfigSource,
717) -> Result<LoadedConfig, ConfigError> {
718 let path = path.into();
719 let format = ConfigFormat::from_path(&path)
720 .ok_or_else(|| ConfigError::UnknownFormat { path: path.clone() })?;
721 let contents = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
722 path: path.clone(),
723 source,
724 })?;
725 let config = parse_config(format, &contents).map_err(|source| ConfigError::Parse {
726 path: path.clone(),
727 format,
728 source,
729 })?;
730 let unknown_keys =
731 unknown_top_level_config_keys(format, &contents).map_err(|source| ConfigError::Parse {
732 path: path.clone(),
733 format,
734 source,
735 })?;
736
737 Ok(LoadedConfig {
738 path,
739 format,
740 source,
741 local_override: None,
742 config,
743 unknown_keys,
744 })
745}
746
747pub fn parse_config(format: ConfigFormat, contents: &str) -> Result<BindPortConfig, String> {
748 match format {
749 ConfigFormat::Toml => toml::from_str(contents).map_err(|error| error.to_string()),
750 ConfigFormat::Json => serde_json::from_str(contents).map_err(|error| error.to_string()),
751 ConfigFormat::Yaml => serde_yaml_ng::from_str(contents).map_err(|error| error.to_string()),
752 }
753}
754
755pub fn resolve_identity(sources: IdentitySources<'_>) -> ServiceIdentity {
756 let git = detect_git_identity(sources.cwd);
757 let package = package_inference(sources.cwd, git.as_ref());
758 let project = first_non_empty([
759 sources.cli_project,
760 sources.env_project,
761 sources.config_project,
762 ])
763 .map(str::to_owned)
764 .or_else(|| package.project_name())
765 .unwrap_or_else(|| infer_project_name(sources.cwd, git.as_ref()));
766 let service = first_non_empty([
767 sources.cli_service,
768 sources.env_service,
769 sources.config_service,
770 ])
771 .map(str::to_owned)
772 .or_else(|| package.service_name())
773 .unwrap_or_else(|| infer_service_name(sources.command));
774 let identity_key = identity_key(&project, &service, sources.cwd, git.as_ref());
775
776 ServiceIdentity {
777 project,
778 service,
779 git,
780 identity_key,
781 }
782}
783
784pub fn detect_git_identity(cwd: &Path) -> Option<GitIdentity> {
785 let worktree_path = git_output(cwd, ["rev-parse", "--show-toplevel"])?;
786 let worktree_path = absolute_path(cwd, PathBuf::from(worktree_path));
787 let git_common_dir = git_output(cwd, ["rev-parse", "--git-common-dir"])?;
788 let git_common_dir = absolute_path(cwd, PathBuf::from(git_common_dir));
789 let commit = git_output(cwd, ["rev-parse", "--short", "HEAD"])?;
790 let branch = git_output(cwd, ["branch", "--show-current"])
791 .filter(|branch| !branch.is_empty())
792 .unwrap_or_else(|| format!("detached-{commit}"));
793 let branch_label = normalize_branch_label(&branch);
794 let worktree_hash = stable_path_hash(&worktree_path);
795
796 Some(GitIdentity {
797 worktree_path,
798 worktree_hash,
799 git_common_dir,
800 branch,
801 branch_label,
802 commit,
803 })
804}
805
806pub fn normalize_branch_label(branch: &str) -> String {
807 let mut label = String::new();
808 let mut previous_was_separator = false;
809
810 for character in branch.chars() {
811 if character.is_ascii_alphanumeric() {
812 label.push(character.to_ascii_lowercase());
813 previous_was_separator = false;
814 } else if !previous_was_separator && !label.is_empty() {
815 label.push('-');
816 previous_was_separator = true;
817 }
818 }
819
820 while label.ends_with('-') {
821 label.pop();
822 }
823
824 if label.is_empty() {
825 String::from("branch")
826 } else {
827 label
828 }
829}
830
831fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Option<String> {
832 let output = Command::new("git")
833 .arg("-c")
834 .arg("core.fsmonitor=")
835 .arg("-c")
836 .arg("core.pager=cat")
837 .arg("-C")
838 .arg(cwd)
839 .args(args)
840 .env("GIT_OPTIONAL_LOCKS", "0")
841 .output()
842 .ok()?;
843
844 if !output.status.success() {
845 return None;
846 }
847
848 let value = String::from_utf8(output.stdout).ok()?;
849 let value = value.trim();
850
851 if value.is_empty() {
852 None
853 } else {
854 Some(value.to_owned())
855 }
856}
857
858fn absolute_path(cwd: &Path, path: PathBuf) -> PathBuf {
859 let path = if path.is_absolute() {
860 path
861 } else {
862 cwd.join(path)
863 };
864
865 path.canonicalize().unwrap_or(path)
866}
867
868fn first_non_empty<const N: usize>(values: [Option<&str>; N]) -> Option<&str> {
869 values
870 .into_iter()
871 .flatten()
872 .map(str::trim)
873 .find(|value| !value.is_empty())
874}
875
876fn infer_project_name(cwd: &Path, git: Option<&GitIdentity>) -> String {
877 git.map(|git| git.worktree_path.as_path())
878 .unwrap_or(cwd)
879 .file_name()
880 .and_then(|name| name.to_str())
881 .filter(|name| !name.is_empty())
882 .unwrap_or("unknown")
883 .to_owned()
884}
885
886fn infer_service_name(command: &[String]) -> String {
887 command
888 .first()
889 .and_then(|program| Path::new(program).file_stem())
890 .and_then(|name| name.to_str())
891 .filter(|name| !name.is_empty())
892 .unwrap_or("command")
893 .to_owned()
894}
895
896#[derive(Debug, Clone, PartialEq, Eq)]
897struct PackageInference {
898 root: Option<PackageMetadata>,
899 nearest: Option<PackageMetadata>,
900}
901
902impl PackageInference {
903 fn project_name(&self) -> Option<String> {
904 self.root
905 .as_ref()
906 .or(self.nearest.as_ref())
907 .map(|package| package.identity_name.clone())
908 }
909
910 fn service_name(&self) -> Option<String> {
911 self.nearest
912 .as_ref()
913 .map(|package| package.identity_name.clone())
914 }
915}
916
917#[derive(Debug, Clone, PartialEq, Eq)]
918struct PackageMetadata {
919 identity_name: String,
920}
921
922fn package_inference(cwd: &Path, git: Option<&GitIdentity>) -> PackageInference {
923 let root = git.and_then(|git| read_package_metadata(&git.worktree_path));
924 let nearest = nearest_package_metadata(cwd, git.map(|git| git.worktree_path.as_path()));
925
926 PackageInference { root, nearest }
927}
928
929fn nearest_package_metadata(cwd: &Path, boundary: Option<&Path>) -> Option<PackageMetadata> {
930 let cwd = absolute_path(cwd, cwd.to_path_buf());
931
932 for directory in cwd.ancestors() {
933 if let Some(boundary) = boundary
934 && !directory.starts_with(boundary)
935 {
936 break;
937 }
938
939 if let Some(package) = read_package_metadata(directory) {
940 return Some(package);
941 }
942
943 if Some(directory) == boundary {
944 break;
945 }
946 }
947
948 None
949}
950
951fn read_package_metadata(directory: &Path) -> Option<PackageMetadata> {
952 let contents = fs::read_to_string(directory.join("package.json")).ok()?;
953 let value = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
954 let name = value.get("name")?.as_str()?;
955 let identity_name = package_identity_name(name)?;
956
957 Some(PackageMetadata { identity_name })
958}
959
960fn package_identity_name(name: &str) -> Option<String> {
961 let name = name.trim();
962 if name.is_empty() {
963 return None;
964 }
965
966 let name = if let Some(scoped) = name.strip_prefix('@') {
967 scoped.split_once('/').map(|(_, package)| package)?
968 } else {
969 name
970 };
971 let name = name.trim();
972
973 if name.is_empty() {
974 None
975 } else {
976 Some(name.to_owned())
977 }
978}
979
980fn identity_key(project: &str, service: &str, cwd: &Path, git: Option<&GitIdentity>) -> String {
981 let (path_hash, branch_label) = git
982 .map(|git| (git.worktree_hash.as_str(), git.branch_label.as_str()))
983 .unwrap_or_else(|| ("no-git", "no-branch"));
984 let path_hash = if path_hash == "no-git" {
985 stable_path_hash(&absolute_path(cwd, cwd.to_path_buf()))
986 } else {
987 path_hash.to_owned()
988 };
989
990 format!(
991 "v1:p{}:{project}:s{}:{service}:w{path_hash}:b{}:{branch_label}",
992 project.len(),
993 service.len(),
994 branch_label.len()
995 )
996}
997
998pub fn stable_port_scan_start(seed: &str, range: PortRange) -> Option<u16> {
999 if range.is_empty() {
1000 return None;
1001 }
1002
1003 let offset = stable_hash(seed.as_bytes()) % u64::from(range.len());
1004 let port = range.start as u32 + u32::try_from(offset).expect("range length fits in u32");
1005
1006 Some(u16::try_from(port).expect("port remains within configured range"))
1007}
1008
1009fn stable_path_hash(path: &Path) -> String {
1010 let path = path.to_string_lossy();
1011
1012 format!("{:016x}", stable_hash(path.as_bytes()))
1013}
1014
1015fn stable_hash(bytes: &[u8]) -> u64 {
1016 let mut hash = 0xcbf29ce484222325_u64;
1017
1018 for byte in bytes {
1019 hash ^= u64::from(*byte);
1020 hash = hash.wrapping_mul(0x100000001b3);
1021 }
1022
1023 hash
1024}
1025
1026fn unknown_top_level_config_keys(
1027 format: ConfigFormat,
1028 contents: &str,
1029) -> Result<Vec<String>, String> {
1030 match format {
1031 ConfigFormat::Toml => {
1032 let table = contents
1033 .parse::<toml::Table>()
1034 .map_err(|error| error.to_string())?;
1035 Ok(unknown_config_keys(table.keys().map(String::as_str)))
1036 }
1037 ConfigFormat::Json => {
1038 let value = serde_json::from_str::<serde_json::Value>(contents)
1039 .map_err(|error| error.to_string())?;
1040 let Some(object) = value.as_object() else {
1041 return Ok(Vec::new());
1042 };
1043 Ok(unknown_config_keys(object.keys().map(String::as_str)))
1044 }
1045 ConfigFormat::Yaml => {
1046 let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(contents)
1047 .map_err(|error| error.to_string())?;
1048 let Some(mapping) = value.as_mapping() else {
1049 return Ok(Vec::new());
1050 };
1051 Ok(unknown_config_keys(
1052 mapping.keys().filter_map(serde_yaml_ng::Value::as_str),
1053 ))
1054 }
1055 }
1056}
1057
1058fn unknown_config_keys<'a>(keys: impl IntoIterator<Item = &'a str>) -> Vec<String> {
1059 let mut keys = keys
1060 .into_iter()
1061 .filter(|key| !APPLIED_CONFIG_KEYS.contains(key))
1062 .map(str::to_owned)
1063 .collect::<Vec<_>>();
1064 keys.sort();
1065 keys.dedup();
1066 keys
1067}
1068
1069pub fn parse_port_range(value: &str) -> Result<PortRange, PortRangeParseError> {
1070 let (start, end) = value
1071 .split_once('-')
1072 .ok_or(PortRangeParseError::MissingSeparator)?;
1073 let start = start
1074 .trim()
1075 .parse::<u16>()
1076 .map_err(|_| PortRangeParseError::InvalidStart(start.trim().to_owned()))?;
1077 let end = end
1078 .trim()
1079 .parse::<u16>()
1080 .map_err(|_| PortRangeParseError::InvalidEnd(end.trim().to_owned()))?;
1081 let range = PortRange { start, end };
1082
1083 if range.is_empty() {
1084 return Err(PortRangeParseError::Empty(range));
1085 }
1086
1087 Ok(range)
1088}
1089
1090pub fn default_fallback_config() -> String {
1091 let skip_ports = DEFAULT_SKIP_PORTS
1092 .iter()
1093 .map(u16::to_string)
1094 .collect::<Vec<_>>()
1095 .join(", ");
1096
1097 format!(
1098 "# BindPort fallback config. Project .bindport.* files discovered upward override this file.\n\
1099 # This file is optional; BindPort uses built-in defaults when no config exists.\n\
1100 default_range = \"{}-{}\"\n\
1101 skip_ports = [{}]\n\
1102 \n\
1103 [dashboard]\n\
1104 host = \"127.0.0.1\"\n\
1105 port = 27080\n\
1106 register_service = false\n\
1107 allowed_hosts = [\"localhost\", \"127.0.0.1\"]\n\
1108 \n\
1109 [dashboard.auth]\n\
1110 required = false\n\
1111 token_env = \"BINDPORT_DASHBOARD_TOKEN\"\n",
1112 DEFAULT_PORT_RANGE.start, DEFAULT_PORT_RANGE.end, skip_ports
1113 )
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use std::{
1120 process::Command,
1121 time::{SystemTime, UNIX_EPOCH},
1122 };
1123
1124 #[test]
1125 fn default_range_matches_roadmap() {
1126 assert_eq!(DEFAULT_PORT_RANGE.start, 29_000);
1127 assert_eq!(DEFAULT_PORT_RANGE.end, 29_999);
1128 assert_eq!(DEFAULT_PORT_RANGE.len(), 1_000);
1129 }
1130
1131 #[test]
1132 fn inverted_range_is_empty() {
1133 let range = PortRange { start: 100, end: 0 };
1134
1135 assert!(range.is_empty());
1136 assert_eq!(range.len(), 0);
1137 }
1138
1139 #[test]
1140 fn default_skiplist_marks_reserved_ports() {
1141 assert!(is_default_skip_port(29_000));
1142 assert!(is_default_skip_port(29_999));
1143 assert!(!is_default_skip_port(29_500));
1144 }
1145
1146 #[test]
1147 fn config_filenames_preserve_format_precedence() {
1148 assert_eq!(
1149 CONFIG_FILENAMES,
1150 [".bindport.toml", ".bindport.json", ".bindport.yaml"]
1151 );
1152 }
1153
1154 #[test]
1155 fn parses_config_formats() {
1156 let toml = parse_config(
1157 ConfigFormat::Toml,
1158 "project = \"demo\"\ndefault_range = \"29100-29199\"\nskip_ports = [29100]\n[dashboard]\nhost = \"127.0.0.1\"\nport = 27080\nregister_service = true\nallowed_hosts = [\"localhost\"]\n[dashboard.auth]\nrequired = true\ntoken_env = \"BINDPORT_DASHBOARD_TOKEN\"\n[[services]]\nname = \"web\"\nhostname = \"{branch}.{project}.localhost\"\nenv.PORT = \"{port}\"\nenv.NEXT_PUBLIC_BINDPORT_URL = \"{route_url}\"\n",
1159 )
1160 .expect("toml config");
1161 let json = parse_config(
1162 ConfigFormat::Json,
1163 r#"{"project":"demo","default_range":"29100-29199","skip_ports":[29100],"dashboard":{"host":"127.0.0.1","port":27080,"register_service":true,"allowed_hosts":["localhost"],"auth":{"required":true,"token_env":"BINDPORT_DASHBOARD_TOKEN"}},"services":[{"name":"web","hostname":"{branch}.{project}.localhost","env":{"PORT":"{port}","NEXT_PUBLIC_BINDPORT_URL":"{route_url}"}}]}"#,
1164 )
1165 .expect("json config");
1166 let yaml = parse_config(
1167 ConfigFormat::Yaml,
1168 "project: demo\ndefault_range: 29100-29199\nskip_ports:\n - 29100\ndashboard:\n host: 127.0.0.1\n port: 27080\n register_service: true\n allowed_hosts:\n - localhost\n auth:\n required: true\n token_env: BINDPORT_DASHBOARD_TOKEN\nservices:\n - name: web\n hostname: \"{branch}.{project}.localhost\"\n env:\n PORT: \"{port}\"\n NEXT_PUBLIC_BINDPORT_URL: \"{route_url}\"\n",
1169 )
1170 .expect("yaml config");
1171
1172 assert_eq!(toml, json);
1173 assert_eq!(json, yaml);
1174 let dashboard = toml.dashboard.as_ref().expect("dashboard config");
1175 assert_eq!(dashboard.host.as_deref(), Some("127.0.0.1"));
1176 assert_eq!(dashboard.port, Some(27_080));
1177 assert_eq!(dashboard.register_service, Some(true));
1178 assert_eq!(
1179 dashboard.allowed_hosts,
1180 Some(vec![String::from("localhost")])
1181 );
1182 let auth = dashboard.auth.as_ref().expect("dashboard auth");
1183 assert_eq!(auth.required, Some(true));
1184 assert_eq!(auth.token_env.as_deref(), Some("BINDPORT_DASHBOARD_TOKEN"));
1185 let service = toml.service_config("web").expect("service config by name");
1186 assert_eq!(
1187 service.hostname.as_deref(),
1188 Some("{branch}.{project}.localhost")
1189 );
1190 assert_eq!(
1191 service
1192 .env
1193 .as_ref()
1194 .and_then(|env| env.get("NEXT_PUBLIC_BINDPORT_URL"))
1195 .map(String::as_str),
1196 Some("{route_url}")
1197 );
1198 assert_eq!(toml.configured_service_name(), Some("web"));
1199 }
1200
1201 #[test]
1202 fn parses_output_config_formats() {
1203 let toml = parse_config(
1204 ConfigFormat::Toml,
1205 "project = \"demo\"\n[output_defaults]\nroot = \".bindport/generated\"\ntarget_host = \"127.0.0.1\"\ntarget_scheme = \"http\"\nauto_render = true\ndelete_on = [\"removed\"]\non_failure = \"warn\"\ndebounce_ms = 250\n[[outputs]]\nname = \"traefik\"\ntemplate = \"bindport-traefik\"\ntarget = \"traefik/{{ route.slug }}.yml\"\n[outputs.vars]\nentrypoints = [\"web\"]\ntls = false\n",
1206 )
1207 .expect("toml config");
1208 let json = parse_config(
1209 ConfigFormat::Json,
1210 r#"{"project":"demo","output_defaults":{"root":".bindport/generated","target_host":"127.0.0.1","target_scheme":"http","auto_render":true,"delete_on":["removed"],"on_failure":"warn","debounce_ms":250},"outputs":[{"name":"traefik","template":"bindport-traefik","target":"traefik/{{ route.slug }}.yml","vars":{"entrypoints":["web"],"tls":false}}]}"#,
1211 )
1212 .expect("json config");
1213 let yaml = parse_config(
1214 ConfigFormat::Yaml,
1215 "project: demo\noutput_defaults:\n root: .bindport/generated\n target_host: 127.0.0.1\n target_scheme: http\n auto_render: true\n delete_on:\n - removed\n on_failure: warn\n debounce_ms: 250\noutputs:\n - name: traefik\n template: bindport-traefik\n target: traefik/{{ route.slug }}.yml\n vars:\n entrypoints:\n - web\n tls: false\n",
1216 )
1217 .expect("yaml config");
1218
1219 assert_eq!(toml, json);
1220 assert_eq!(json, yaml);
1221 let defaults = toml.output_defaults.as_ref().expect("output defaults");
1222 assert_eq!(defaults.root.as_deref(), Some(".bindport/generated"));
1223 assert_eq!(defaults.delete_on, Some(vec![OutputDeleteState::Removed]));
1224 assert_eq!(defaults.on_failure, Some(OutputFailurePolicy::Warn));
1225 assert_eq!(defaults.debounce_ms, Some(250));
1226
1227 let output = toml.output_config("traefik").expect("output by name");
1228 assert_eq!(output.template.as_deref(), Some("bindport-traefik"));
1229 assert_eq!(
1230 output
1231 .vars
1232 .as_ref()
1233 .and_then(|vars| vars.get("entrypoints")),
1234 Some(&serde_json::json!(["web"]))
1235 );
1236 assert_eq!(
1237 output.vars.as_ref().and_then(|vars| vars.get("tls")),
1238 Some(&serde_json::json!(false))
1239 );
1240 }
1241
1242 #[test]
1243 fn local_override_merges_output_config_by_name() {
1244 let root = temp_test_dir("local-output-override");
1245 fs::write(
1246 root.join(".bindport.toml"),
1247 "project = \"base-project\"\n[output_defaults]\nroot = \".bindport/generated\"\ndebounce_ms = 250\n[[outputs]]\nname = \"traefik\"\ntemplate = \"bindport-traefik\"\ntarget = \"traefik/{{ route.slug }}.yml\"\n[outputs.vars]\nentrypoints = [\"web\"]\ntls = false\n[[outputs]]\nname = \"debug\"\ntemplate = \"debug-route\"\ntarget = \"debug/{{ route.slug }}.txt\"\n",
1248 )
1249 .expect("write base config");
1250 fs::write(
1251 root.join(".bindport.local.toml"),
1252 "project = \"local-project\"\n[output_defaults]\nroot = \"/tmp/bindport-traefik\"\n[[outputs]]\nname = \"traefik\"\ntarget = \"{{ route.slug }}.yml\"\n[outputs.vars]\nentrypoints = [\"websecure\"]\n[[outputs]]\nname = \"extra\"\ntemplate = \"extra-template\"\ntarget = \"extra/{{ route.slug }}.txt\"\n",
1253 )
1254 .expect("write local override");
1255
1256 let loaded = discover_config(&root, None)
1257 .expect("discover config")
1258 .expect("loaded config");
1259
1260 assert_eq!(loaded.config.project.as_deref(), Some("local-project"));
1261 assert_eq!(
1262 loaded
1263 .local_override
1264 .as_ref()
1265 .map(|local| local.path.as_path()),
1266 Some(root.join(".bindport.local.toml").as_path())
1267 );
1268 let defaults = loaded
1269 .config
1270 .output_defaults
1271 .as_ref()
1272 .expect("output defaults");
1273 assert_eq!(defaults.root.as_deref(), Some("/tmp/bindport-traefik"));
1274 assert_eq!(defaults.debounce_ms, Some(250));
1275
1276 let traefik = loaded
1277 .config
1278 .output_config("traefik")
1279 .expect("merged traefik output");
1280 assert_eq!(traefik.template.as_deref(), Some("bindport-traefik"));
1281 assert_eq!(traefik.target.as_deref(), Some("{{ route.slug }}.yml"));
1282 assert_eq!(
1283 traefik
1284 .vars
1285 .as_ref()
1286 .and_then(|vars| vars.get("entrypoints")),
1287 Some(&serde_json::json!(["websecure"]))
1288 );
1289 assert_eq!(
1290 traefik.vars.as_ref().and_then(|vars| vars.get("tls")),
1291 Some(&serde_json::json!(false))
1292 );
1293 assert!(loaded.config.output_config("debug").is_some());
1294 assert!(loaded.config.output_config("extra").is_some());
1295 }
1296
1297 #[test]
1298 fn effective_outputs_apply_defaults_and_skip_disabled_entries() {
1299 let config = parse_config(
1300 ConfigFormat::Toml,
1301 "project = \"demo\"\n[output_defaults]\nroot = \".bindport/generated\"\ntarget_host = \"host.docker.internal\"\ntarget_scheme = \"https\"\nauto_render = false\ndelete_on = [\"stopped\", \"removed\"]\non_failure = \"block\"\ndebounce_ms = 500\n[[outputs]]\nname = \"traefik\"\ntemplate = \"bindport-traefik\"\ntarget = \"traefik/{{ route.slug }}.yml\"\n[outputs.vars]\nentrypoints = [\"websecure\"]\n[[outputs]]\nname = \"disabled\"\nenabled = false\n",
1302 )
1303 .expect("config");
1304
1305 let outputs = config.effective_outputs().expect("effective outputs");
1306
1307 assert_eq!(outputs.len(), 1);
1308 let output = &outputs[0];
1309 assert_eq!(output.name, "traefik");
1310 assert_eq!(output.template, "bindport-traefik");
1311 assert_eq!(output.root.as_deref(), Some(".bindport/generated"));
1312 assert_eq!(output.target, "traefik/{{ route.slug }}.yml");
1313 assert_eq!(output.target_host, "host.docker.internal");
1314 assert_eq!(output.target_scheme, "https");
1315 assert!(!output.auto_render);
1316 assert_eq!(
1317 output.delete_on,
1318 vec![OutputDeleteState::Stopped, OutputDeleteState::Removed]
1319 );
1320 assert_eq!(output.on_failure, OutputFailurePolicy::Block);
1321 assert_eq!(output.debounce_ms, 500);
1322 assert_eq!(
1323 output.vars.get("entrypoints"),
1324 Some(&serde_json::json!(["websecure"]))
1325 );
1326 }
1327
1328 #[test]
1329 fn effective_outputs_use_builtin_defaults() {
1330 let config = parse_config(
1331 ConfigFormat::Toml,
1332 "[[outputs]]\nname = \"traefik\"\ntemplate = \"bindport-traefik\"\ntarget = \"{{ route.slug }}.yml\"\n",
1333 )
1334 .expect("config");
1335
1336 let output = config
1337 .effective_outputs()
1338 .expect("effective outputs")
1339 .pop()
1340 .expect("output");
1341
1342 assert_eq!(output.root, None);
1343 assert_eq!(output.target_host, DEFAULT_OUTPUT_TARGET_HOST);
1344 assert_eq!(output.target_scheme, DEFAULT_OUTPUT_TARGET_SCHEME);
1345 assert_eq!(output.auto_render, DEFAULT_OUTPUT_AUTO_RENDER);
1346 assert_eq!(output.delete_on, vec![OutputDeleteState::Removed]);
1347 assert_eq!(output.on_failure, OutputFailurePolicy::Warn);
1348 assert_eq!(output.debounce_ms, DEFAULT_OUTPUT_DEBOUNCE_MS);
1349 }
1350
1351 #[test]
1352 fn effective_outputs_report_required_field_errors() {
1353 let missing_name = BindPortConfig {
1354 outputs: Some(vec![OutputConfig {
1355 template: Some(String::from("bindport-traefik")),
1356 target: Some(String::from("{{ route.slug }}.yml")),
1357 ..OutputConfig::default()
1358 }]),
1359 ..BindPortConfig::default()
1360 };
1361 assert!(matches!(
1362 missing_name.effective_outputs(),
1363 Err(OutputConfigError::MissingName { index: 0 })
1364 ));
1365
1366 let missing_template = BindPortConfig {
1367 outputs: Some(vec![OutputConfig {
1368 name: Some(String::from("traefik")),
1369 target: Some(String::from("{{ route.slug }}.yml")),
1370 ..OutputConfig::default()
1371 }]),
1372 ..BindPortConfig::default()
1373 };
1374 assert!(matches!(
1375 missing_template.effective_outputs(),
1376 Err(OutputConfigError::MissingTemplate { name }) if name == "traefik"
1377 ));
1378 }
1379
1380 #[test]
1381 fn local_override_filenames_preserve_format_precedence() {
1382 assert_eq!(
1383 LOCAL_CONFIG_FILENAMES,
1384 [
1385 ".bindport.local.toml",
1386 ".bindport.local.json",
1387 ".bindport.local.yaml",
1388 ".bindport.local.yml",
1389 "bindport.local.toml",
1390 "bindport.local.json",
1391 "bindport.local.yaml",
1392 "bindport.local.yml"
1393 ]
1394 );
1395 }
1396
1397 #[test]
1398 fn reports_unknown_top_level_config_keys() {
1399 let keys = unknown_top_level_config_keys(
1400 ConfigFormat::Toml,
1401 "project = \"demo\"\ndefaultrange = \"29100-29199\"\n[proxy.traefik]\nenabled = true\n",
1402 )
1403 .expect("unknown keys");
1404
1405 assert_eq!(keys, ["defaultrange", "proxy"]);
1406 }
1407
1408 #[test]
1409 fn normalizes_branch_labels_for_hostnames() {
1410 assert_eq!(normalize_branch_label("feature/tree"), "feature-tree");
1411 assert_eq!(
1412 normalize_branch_label("BUGFIX/JIRA-123_widget"),
1413 "bugfix-jira-123-widget"
1414 );
1415 assert_eq!(normalize_branch_label("!!!"), "branch");
1416 }
1417
1418 #[test]
1419 fn identity_sources_follow_precedence() {
1420 let cwd = Path::new("/tmp/bindport");
1421 let command = [String::from("next")];
1422
1423 let identity = resolve_identity(IdentitySources {
1424 cwd,
1425 command: &command,
1426 cli_project: None,
1427 cli_service: Some("cli-service"),
1428 env_project: Some("env-project"),
1429 env_service: Some("env-service"),
1430 config_project: Some("config-project"),
1431 config_service: Some("config-service"),
1432 });
1433
1434 assert_eq!(identity.project, "env-project");
1435 assert_eq!(identity.service, "cli-service");
1436 }
1437
1438 #[test]
1439 fn config_identity_beats_inference() {
1440 let cwd = Path::new("/tmp/bindport");
1441 let command = [String::from("next")];
1442
1443 let identity = resolve_identity(IdentitySources {
1444 cwd,
1445 command: &command,
1446 cli_project: None,
1447 cli_service: None,
1448 env_project: None,
1449 env_service: None,
1450 config_project: Some("config-project"),
1451 config_service: Some("config-service"),
1452 });
1453
1454 assert_eq!(identity.project, "config-project");
1455 assert_eq!(identity.service, "config-service");
1456 }
1457
1458 #[test]
1459 fn package_metadata_infers_standalone_identity() {
1460 let root = temp_test_dir("package-standalone");
1461 fs::write(root.join("package.json"), r#"{"name":"@stutz/hoststamp"}"#)
1462 .expect("write package json");
1463 let command = [String::from("next")];
1464
1465 let identity = resolve_identity(IdentitySources {
1466 cwd: &root,
1467 command: &command,
1468 cli_project: None,
1469 cli_service: None,
1470 env_project: None,
1471 env_service: None,
1472 config_project: None,
1473 config_service: None,
1474 });
1475
1476 assert_eq!(identity.project, "hoststamp");
1477 assert_eq!(identity.service, "hoststamp");
1478 }
1479
1480 #[test]
1481 fn package_metadata_uses_git_root_project_and_nearest_service() {
1482 let root = temp_test_dir("package-monorepo");
1483 git(&root, ["init"]);
1484 git(&root, ["config", "user.email", "bindport@example.invalid"]);
1485 git(&root, ["config", "user.name", "BindPort Test"]);
1486 git(&root, ["config", "commit.gpgsign", "false"]);
1487 fs::write(root.join("package.json"), r#"{"name":"hoststamp"}"#)
1488 .expect("write root package json");
1489 let service = root.join("apps").join("web");
1490 fs::create_dir_all(&service).expect("service dir");
1491 fs::write(service.join("package.json"), r#"{"name":"@hoststamp/web"}"#)
1492 .expect("write service package json");
1493 fs::write(root.join("README.md"), "test\n").expect("write fixture");
1494 git(
1495 &root,
1496 ["add", "README.md", "package.json", "apps/web/package.json"],
1497 );
1498 git(&root, ["commit", "-m", "initial"]);
1499 let command = [String::from("next")];
1500
1501 let identity = resolve_identity(IdentitySources {
1502 cwd: &service,
1503 command: &command,
1504 cli_project: None,
1505 cli_service: None,
1506 env_project: None,
1507 env_service: None,
1508 config_project: None,
1509 config_service: None,
1510 });
1511
1512 assert_eq!(identity.project, "hoststamp");
1513 assert_eq!(identity.service, "web");
1514 assert!(identity.git.is_some());
1515 }
1516
1517 #[test]
1518 fn explicit_identity_beats_package_metadata() {
1519 let root = temp_test_dir("package-explicit");
1520 fs::write(root.join("package.json"), r#"{"name":"package-project"}"#)
1521 .expect("write package json");
1522 let command = [String::from("next")];
1523
1524 let identity = resolve_identity(IdentitySources {
1525 cwd: &root,
1526 command: &command,
1527 cli_project: None,
1528 cli_service: Some("cli-service"),
1529 env_project: Some("env-project"),
1530 env_service: Some("env-service"),
1531 config_project: Some("config-project"),
1532 config_service: Some("config-service"),
1533 });
1534
1535 assert_eq!(identity.project, "env-project");
1536 assert_eq!(identity.service, "cli-service");
1537 }
1538
1539 #[test]
1540 fn invalid_package_metadata_falls_back_to_directory_and_command() {
1541 let root = temp_test_dir("package-invalid");
1542 fs::write(root.join("package.json"), r#"{"name":""}"#).expect("write package json");
1543 let command = [String::from("next")];
1544
1545 let identity = resolve_identity(IdentitySources {
1546 cwd: &root,
1547 command: &command,
1548 cli_project: None,
1549 cli_service: None,
1550 env_project: None,
1551 env_service: None,
1552 config_project: None,
1553 config_service: None,
1554 });
1555
1556 assert_eq!(
1557 identity.project,
1558 root.file_name().unwrap().to_str().unwrap()
1559 );
1560 assert_eq!(identity.service, "next");
1561 }
1562
1563 #[test]
1564 fn identity_key_delimits_project_and_service_values() {
1565 let cwd = Path::new("/tmp/bindport");
1566 let command = [String::from("next")];
1567 let first = resolve_identity(IdentitySources {
1568 cwd,
1569 command: &command,
1570 cli_project: Some("a:b"),
1571 cli_service: Some("c"),
1572 env_project: None,
1573 env_service: None,
1574 config_project: None,
1575 config_service: None,
1576 });
1577 let second = resolve_identity(IdentitySources {
1578 cwd,
1579 command: &command,
1580 cli_project: Some("a"),
1581 cli_service: Some("b:c"),
1582 env_project: None,
1583 env_service: None,
1584 config_project: None,
1585 config_service: None,
1586 });
1587
1588 assert_ne!(first.identity_key, second.identity_key);
1589 assert!(first.identity_key.starts_with("v1:"));
1590 }
1591
1592 #[test]
1593 fn identity_port_scan_start_is_stable_and_in_range() {
1594 let identity = ServiceIdentity {
1595 project: String::from("bindport"),
1596 service: String::from("web"),
1597 git: None,
1598 identity_key: String::from("v1:test"),
1599 };
1600 let range = PortRange {
1601 start: 29_100,
1602 end: 29_199,
1603 };
1604 let scan_start = identity.port_scan_start(range).expect("scan start");
1605
1606 assert!(range.contains(scan_start));
1607 assert_eq!(identity.port_scan_start(range), Some(scan_start));
1608 assert_eq!(
1609 identity.port_scan_start(PortRange { start: 100, end: 0 }),
1610 None
1611 );
1612 }
1613
1614 #[test]
1615 fn detects_git_worktree_branch_and_commit() {
1616 let root = temp_test_dir("git-identity");
1617 git(&root, ["init"]);
1618 git(&root, ["config", "user.email", "bindport@example.invalid"]);
1619 git(&root, ["config", "user.name", "BindPort Test"]);
1620 git(&root, ["config", "commit.gpgsign", "false"]);
1621 fs::write(root.join("README.md"), "test\n").expect("write fixture");
1622 git(&root, ["add", "README.md"]);
1623 git(&root, ["commit", "-m", "initial"]);
1624 git(&root, ["checkout", "-B", "feature/tree"]);
1625 let nested = root.join("apps").join("web");
1626 fs::create_dir_all(&nested).expect("nested dir");
1627
1628 let identity = detect_git_identity(&nested).expect("git identity");
1629
1630 assert_eq!(identity.worktree_path, root.canonicalize().expect("root"));
1631 assert_eq!(identity.branch, "feature/tree");
1632 assert_eq!(identity.branch_label, "feature-tree");
1633 assert!(!identity.commit.is_empty());
1634 assert!(!identity.worktree_hash.is_empty());
1635 }
1636
1637 #[test]
1638 fn parses_port_range() {
1639 assert_eq!(
1640 parse_port_range("29100-29199").expect("range"),
1641 PortRange {
1642 start: 29_100,
1643 end: 29_199
1644 }
1645 );
1646 assert!(matches!(
1647 parse_port_range("29199-29100"),
1648 Err(PortRangeParseError::Empty(_))
1649 ));
1650 }
1651
1652 fn temp_test_dir(name: &str) -> PathBuf {
1653 let now = SystemTime::now()
1654 .duration_since(UNIX_EPOCH)
1655 .expect("clock")
1656 .as_nanos();
1657 let path =
1658 std::env::temp_dir().join(format!("bindport-core-{name}-{}-{now}", std::process::id()));
1659
1660 fs::create_dir_all(&path).expect("temp dir");
1661 path
1662 }
1663
1664 fn git<const N: usize>(cwd: &Path, args: [&str; N]) {
1665 let output = Command::new("git")
1666 .arg("-C")
1667 .arg(cwd)
1668 .args(args)
1669 .output()
1670 .expect("run git");
1671
1672 assert!(
1673 output.status.success(),
1674 "git failed: {}",
1675 String::from_utf8_lossy(&output.stderr)
1676 );
1677 }
1678}