1use std::{
4 collections::BTreeMap,
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 CONFIG_FILENAMES: &[&str] = &[".bindport.toml", ".bindport.json", ".bindport.yaml"];
23pub const FALLBACK_CONFIG_FILE: &str = "config.toml";
24pub const APPLIED_CONFIG_KEYS: &[&str] = &[
25 "project",
26 "service",
27 "default_range",
28 "skip_ports",
29 "services",
30 "dashboard",
31];
32pub const BINDPORT_PROJECT_ENV: &str = "BINDPORT_PROJECT";
33pub const BINDPORT_SERVICE_ENV: &str = "BINDPORT_SERVICE";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct PortRange {
37 pub start: u16,
38 pub end: u16,
39}
40
41impl PortRange {
42 pub const fn contains(self, port: u16) -> bool {
43 self.start <= port && port <= self.end
44 }
45
46 pub const fn len(self) -> u32 {
47 if self.is_empty() {
48 0
49 } else {
50 self.end as u32 - self.start as u32 + 1
51 }
52 }
53
54 pub const fn is_empty(self) -> bool {
55 self.start > self.end
56 }
57}
58
59pub fn is_default_skip_port(port: u16) -> bool {
60 DEFAULT_SKIP_PORTS.contains(&port)
61}
62
63#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
64#[serde(default)]
65pub struct BindPortConfig {
66 pub project: Option<String>,
67 pub service: Option<String>,
68 pub default_range: Option<String>,
69 pub skip_ports: Option<Vec<u16>>,
70 pub services: Option<Vec<ServiceConfig>>,
71 pub dashboard: Option<DashboardConfig>,
72}
73
74impl BindPortConfig {
75 pub fn configured_service_name(&self) -> Option<&str> {
76 self.service.as_deref().or(match self.services.as_deref() {
77 Some([service]) => service.name.as_deref(),
78 _ => None,
79 })
80 }
81
82 pub fn service_config(&self, service_name: &str) -> Option<&ServiceConfig> {
83 self.services.as_deref()?.iter().find(|service| {
84 service
85 .name
86 .as_deref()
87 .is_some_and(|name| name == service_name)
88 })
89 }
90}
91
92#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
93#[serde(default)]
94pub struct ServiceConfig {
95 pub name: Option<String>,
96 pub command: Option<String>,
97 pub env: Option<BTreeMap<String, String>>,
98 pub hostname: Option<String>,
99 pub route_url: Option<String>,
100}
101
102#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
103#[serde(default)]
104pub struct DashboardConfig {
105 pub host: Option<String>,
106 pub port: Option<u16>,
107 pub register_service: Option<bool>,
108 pub allowed_hosts: Option<Vec<String>>,
109 pub auth: Option<DashboardAuthConfig>,
110}
111
112#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
113#[serde(default)]
114pub struct DashboardAuthConfig {
115 pub required: Option<bool>,
116 pub token: Option<String>,
117 pub token_env: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct GitIdentity {
122 pub worktree_path: PathBuf,
123 pub worktree_hash: String,
124 pub git_common_dir: PathBuf,
125 pub branch: String,
126 pub branch_label: String,
127 pub commit: String,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ServiceIdentity {
132 pub project: String,
133 pub service: String,
134 pub git: Option<GitIdentity>,
135 pub identity_key: String,
136}
137
138impl ServiceIdentity {
139 pub fn port_scan_start(&self, range: PortRange) -> Option<u16> {
140 stable_port_scan_start(&self.identity_key, range)
141 }
142}
143
144#[derive(Debug, Clone, Copy)]
145pub struct IdentitySources<'a> {
146 pub cwd: &'a Path,
147 pub command: &'a [String],
148 pub cli_project: Option<&'a str>,
149 pub cli_service: Option<&'a str>,
150 pub env_project: Option<&'a str>,
151 pub env_service: Option<&'a str>,
152 pub config_project: Option<&'a str>,
153 pub config_service: Option<&'a str>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ConfigFormat {
158 Toml,
159 Json,
160 Yaml,
161}
162
163impl ConfigFormat {
164 pub fn from_path(path: &Path) -> Option<Self> {
165 match path.extension().and_then(|extension| extension.to_str()) {
166 Some("toml") => Some(Self::Toml),
167 Some("json") => Some(Self::Json),
168 Some("yaml") => Some(Self::Yaml),
169 _ => None,
170 }
171 }
172
173 pub const fn as_str(self) -> &'static str {
174 match self {
175 Self::Toml => "toml",
176 Self::Json => "json",
177 Self::Yaml => "yaml",
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum ConfigSource {
184 Project,
185 Fallback,
186}
187
188impl ConfigSource {
189 pub const fn as_str(self) -> &'static str {
190 match self {
191 Self::Project => "project",
192 Self::Fallback => "fallback",
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct LoadedConfig {
199 pub path: PathBuf,
200 pub format: ConfigFormat,
201 pub source: ConfigSource,
202 pub config: BindPortConfig,
203 pub unknown_keys: Vec<String>,
204}
205
206impl LoadedConfig {
207 pub fn port_range(&self) -> Result<PortRange, ConfigError> {
208 self.config
209 .default_range
210 .as_deref()
211 .map(parse_port_range)
212 .transpose()
213 .map_err(|source| ConfigError::InvalidPortRange {
214 path: self.path.clone(),
215 source,
216 })
217 .map(|range| range.unwrap_or(DEFAULT_PORT_RANGE))
218 }
219
220 pub fn skip_ports(&self) -> Vec<u16> {
221 self.config
222 .skip_ports
223 .clone()
224 .unwrap_or_else(|| DEFAULT_SKIP_PORTS.to_vec())
225 }
226}
227
228#[derive(Debug)]
229pub enum ConfigError {
230 Read {
231 path: PathBuf,
232 source: io::Error,
233 },
234 UnknownFormat {
235 path: PathBuf,
236 },
237 Parse {
238 path: PathBuf,
239 format: ConfigFormat,
240 source: String,
241 },
242 InvalidPortRange {
243 path: PathBuf,
244 source: PortRangeParseError,
245 },
246}
247
248impl fmt::Display for ConfigError {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 match self {
251 Self::Read { path, source } => {
252 write!(f, "failed to read config `{}`: {source}", path.display())
253 }
254 Self::UnknownFormat { path } => {
255 write!(f, "unsupported config format `{}`", path.display())
256 }
257 Self::Parse {
258 path,
259 format,
260 source,
261 } => {
262 write!(
263 f,
264 "failed to parse {} config `{}`: {source}",
265 format.as_str(),
266 path.display()
267 )
268 }
269 Self::InvalidPortRange { path, source } => {
270 write!(
271 f,
272 "invalid default_range in config `{}`: {source}",
273 path.display()
274 )
275 }
276 }
277 }
278}
279
280impl std::error::Error for ConfigError {
281 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
282 match self {
283 Self::Read { source, .. } => Some(source),
284 Self::InvalidPortRange { source, .. } => Some(source),
285 Self::UnknownFormat { .. } | Self::Parse { .. } => None,
286 }
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum PortRangeParseError {
292 MissingSeparator,
293 InvalidStart(String),
294 InvalidEnd(String),
295 Empty(PortRange),
296}
297
298impl fmt::Display for PortRangeParseError {
299 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300 match self {
301 Self::MissingSeparator => write!(f, "expected START-END"),
302 Self::InvalidStart(value) => write!(f, "invalid range start `{value}`"),
303 Self::InvalidEnd(value) => write!(f, "invalid range end `{value}`"),
304 Self::Empty(range) => write!(
305 f,
306 "range start {} must be less than or equal to end {}",
307 range.start, range.end
308 ),
309 }
310 }
311}
312
313impl std::error::Error for PortRangeParseError {}
314
315pub fn discover_config(
316 start: &Path,
317 fallback_path: Option<&Path>,
318) -> Result<Option<LoadedConfig>, ConfigError> {
319 for directory in start.ancestors() {
320 for filename in CONFIG_FILENAMES {
321 let path = directory.join(filename);
322
323 if path.is_file() {
324 return load_config(path, ConfigSource::Project).map(Some);
325 }
326 }
327 }
328
329 if let Some(path) = fallback_path.filter(|path| path.is_file()) {
330 return load_config(path, ConfigSource::Fallback).map(Some);
331 }
332
333 Ok(None)
334}
335
336pub fn load_config(
337 path: impl Into<PathBuf>,
338 source: ConfigSource,
339) -> Result<LoadedConfig, ConfigError> {
340 let path = path.into();
341 let format = ConfigFormat::from_path(&path)
342 .ok_or_else(|| ConfigError::UnknownFormat { path: path.clone() })?;
343 let contents = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
344 path: path.clone(),
345 source,
346 })?;
347 let config = parse_config(format, &contents).map_err(|source| ConfigError::Parse {
348 path: path.clone(),
349 format,
350 source,
351 })?;
352 let unknown_keys =
353 unknown_top_level_config_keys(format, &contents).map_err(|source| ConfigError::Parse {
354 path: path.clone(),
355 format,
356 source,
357 })?;
358
359 Ok(LoadedConfig {
360 path,
361 format,
362 source,
363 config,
364 unknown_keys,
365 })
366}
367
368pub fn parse_config(format: ConfigFormat, contents: &str) -> Result<BindPortConfig, String> {
369 match format {
370 ConfigFormat::Toml => toml::from_str(contents).map_err(|error| error.to_string()),
371 ConfigFormat::Json => serde_json::from_str(contents).map_err(|error| error.to_string()),
372 ConfigFormat::Yaml => serde_yaml_ng::from_str(contents).map_err(|error| error.to_string()),
373 }
374}
375
376pub fn resolve_identity(sources: IdentitySources<'_>) -> ServiceIdentity {
377 let git = detect_git_identity(sources.cwd);
378 let package = package_inference(sources.cwd, git.as_ref());
379 let project = first_non_empty([
380 sources.cli_project,
381 sources.env_project,
382 sources.config_project,
383 ])
384 .map(str::to_owned)
385 .or_else(|| package.project_name())
386 .unwrap_or_else(|| infer_project_name(sources.cwd, git.as_ref()));
387 let service = first_non_empty([
388 sources.cli_service,
389 sources.env_service,
390 sources.config_service,
391 ])
392 .map(str::to_owned)
393 .or_else(|| package.service_name())
394 .unwrap_or_else(|| infer_service_name(sources.command));
395 let identity_key = identity_key(&project, &service, sources.cwd, git.as_ref());
396
397 ServiceIdentity {
398 project,
399 service,
400 git,
401 identity_key,
402 }
403}
404
405pub fn detect_git_identity(cwd: &Path) -> Option<GitIdentity> {
406 let worktree_path = git_output(cwd, ["rev-parse", "--show-toplevel"])?;
407 let worktree_path = absolute_path(cwd, PathBuf::from(worktree_path));
408 let git_common_dir = git_output(cwd, ["rev-parse", "--git-common-dir"])?;
409 let git_common_dir = absolute_path(cwd, PathBuf::from(git_common_dir));
410 let commit = git_output(cwd, ["rev-parse", "--short", "HEAD"])?;
411 let branch = git_output(cwd, ["branch", "--show-current"])
412 .filter(|branch| !branch.is_empty())
413 .unwrap_or_else(|| format!("detached-{commit}"));
414 let branch_label = normalize_branch_label(&branch);
415 let worktree_hash = stable_path_hash(&worktree_path);
416
417 Some(GitIdentity {
418 worktree_path,
419 worktree_hash,
420 git_common_dir,
421 branch,
422 branch_label,
423 commit,
424 })
425}
426
427pub fn normalize_branch_label(branch: &str) -> String {
428 let mut label = String::new();
429 let mut previous_was_separator = false;
430
431 for character in branch.chars() {
432 if character.is_ascii_alphanumeric() {
433 label.push(character.to_ascii_lowercase());
434 previous_was_separator = false;
435 } else if !previous_was_separator && !label.is_empty() {
436 label.push('-');
437 previous_was_separator = true;
438 }
439 }
440
441 while label.ends_with('-') {
442 label.pop();
443 }
444
445 if label.is_empty() {
446 String::from("branch")
447 } else {
448 label
449 }
450}
451
452fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Option<String> {
453 let output = Command::new("git")
454 .arg("-c")
455 .arg("core.fsmonitor=")
456 .arg("-c")
457 .arg("core.pager=cat")
458 .arg("-C")
459 .arg(cwd)
460 .args(args)
461 .env("GIT_OPTIONAL_LOCKS", "0")
462 .output()
463 .ok()?;
464
465 if !output.status.success() {
466 return None;
467 }
468
469 let value = String::from_utf8(output.stdout).ok()?;
470 let value = value.trim();
471
472 if value.is_empty() {
473 None
474 } else {
475 Some(value.to_owned())
476 }
477}
478
479fn absolute_path(cwd: &Path, path: PathBuf) -> PathBuf {
480 let path = if path.is_absolute() {
481 path
482 } else {
483 cwd.join(path)
484 };
485
486 path.canonicalize().unwrap_or(path)
487}
488
489fn first_non_empty<const N: usize>(values: [Option<&str>; N]) -> Option<&str> {
490 values
491 .into_iter()
492 .flatten()
493 .map(str::trim)
494 .find(|value| !value.is_empty())
495}
496
497fn infer_project_name(cwd: &Path, git: Option<&GitIdentity>) -> String {
498 git.map(|git| git.worktree_path.as_path())
499 .unwrap_or(cwd)
500 .file_name()
501 .and_then(|name| name.to_str())
502 .filter(|name| !name.is_empty())
503 .unwrap_or("unknown")
504 .to_owned()
505}
506
507fn infer_service_name(command: &[String]) -> String {
508 command
509 .first()
510 .and_then(|program| Path::new(program).file_stem())
511 .and_then(|name| name.to_str())
512 .filter(|name| !name.is_empty())
513 .unwrap_or("command")
514 .to_owned()
515}
516
517#[derive(Debug, Clone, PartialEq, Eq)]
518struct PackageInference {
519 root: Option<PackageMetadata>,
520 nearest: Option<PackageMetadata>,
521}
522
523impl PackageInference {
524 fn project_name(&self) -> Option<String> {
525 self.root
526 .as_ref()
527 .or(self.nearest.as_ref())
528 .map(|package| package.identity_name.clone())
529 }
530
531 fn service_name(&self) -> Option<String> {
532 self.nearest
533 .as_ref()
534 .map(|package| package.identity_name.clone())
535 }
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
539struct PackageMetadata {
540 identity_name: String,
541}
542
543fn package_inference(cwd: &Path, git: Option<&GitIdentity>) -> PackageInference {
544 let root = git.and_then(|git| read_package_metadata(&git.worktree_path));
545 let nearest = nearest_package_metadata(cwd, git.map(|git| git.worktree_path.as_path()));
546
547 PackageInference { root, nearest }
548}
549
550fn nearest_package_metadata(cwd: &Path, boundary: Option<&Path>) -> Option<PackageMetadata> {
551 let cwd = absolute_path(cwd, cwd.to_path_buf());
552
553 for directory in cwd.ancestors() {
554 if let Some(boundary) = boundary
555 && !directory.starts_with(boundary)
556 {
557 break;
558 }
559
560 if let Some(package) = read_package_metadata(directory) {
561 return Some(package);
562 }
563
564 if Some(directory) == boundary {
565 break;
566 }
567 }
568
569 None
570}
571
572fn read_package_metadata(directory: &Path) -> Option<PackageMetadata> {
573 let contents = fs::read_to_string(directory.join("package.json")).ok()?;
574 let value = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
575 let name = value.get("name")?.as_str()?;
576 let identity_name = package_identity_name(name)?;
577
578 Some(PackageMetadata { identity_name })
579}
580
581fn package_identity_name(name: &str) -> Option<String> {
582 let name = name.trim();
583 if name.is_empty() {
584 return None;
585 }
586
587 let name = if let Some(scoped) = name.strip_prefix('@') {
588 scoped.split_once('/').map(|(_, package)| package)?
589 } else {
590 name
591 };
592 let name = name.trim();
593
594 if name.is_empty() {
595 None
596 } else {
597 Some(name.to_owned())
598 }
599}
600
601fn identity_key(project: &str, service: &str, cwd: &Path, git: Option<&GitIdentity>) -> String {
602 let (path_hash, branch_label) = git
603 .map(|git| (git.worktree_hash.as_str(), git.branch_label.as_str()))
604 .unwrap_or_else(|| ("no-git", "no-branch"));
605 let path_hash = if path_hash == "no-git" {
606 stable_path_hash(&absolute_path(cwd, cwd.to_path_buf()))
607 } else {
608 path_hash.to_owned()
609 };
610
611 format!(
612 "v1:p{}:{project}:s{}:{service}:w{path_hash}:b{}:{branch_label}",
613 project.len(),
614 service.len(),
615 branch_label.len()
616 )
617}
618
619pub fn stable_port_scan_start(seed: &str, range: PortRange) -> Option<u16> {
620 if range.is_empty() {
621 return None;
622 }
623
624 let offset = stable_hash(seed.as_bytes()) % u64::from(range.len());
625 let port = range.start as u32 + u32::try_from(offset).expect("range length fits in u32");
626
627 Some(u16::try_from(port).expect("port remains within configured range"))
628}
629
630fn stable_path_hash(path: &Path) -> String {
631 let path = path.to_string_lossy();
632
633 format!("{:016x}", stable_hash(path.as_bytes()))
634}
635
636fn stable_hash(bytes: &[u8]) -> u64 {
637 let mut hash = 0xcbf29ce484222325_u64;
638
639 for byte in bytes {
640 hash ^= u64::from(*byte);
641 hash = hash.wrapping_mul(0x100000001b3);
642 }
643
644 hash
645}
646
647fn unknown_top_level_config_keys(
648 format: ConfigFormat,
649 contents: &str,
650) -> Result<Vec<String>, String> {
651 match format {
652 ConfigFormat::Toml => {
653 let table = contents
654 .parse::<toml::Table>()
655 .map_err(|error| error.to_string())?;
656 Ok(unknown_config_keys(table.keys().map(String::as_str)))
657 }
658 ConfigFormat::Json => {
659 let value = serde_json::from_str::<serde_json::Value>(contents)
660 .map_err(|error| error.to_string())?;
661 let Some(object) = value.as_object() else {
662 return Ok(Vec::new());
663 };
664 Ok(unknown_config_keys(object.keys().map(String::as_str)))
665 }
666 ConfigFormat::Yaml => {
667 let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(contents)
668 .map_err(|error| error.to_string())?;
669 let Some(mapping) = value.as_mapping() else {
670 return Ok(Vec::new());
671 };
672 Ok(unknown_config_keys(
673 mapping.keys().filter_map(serde_yaml_ng::Value::as_str),
674 ))
675 }
676 }
677}
678
679fn unknown_config_keys<'a>(keys: impl IntoIterator<Item = &'a str>) -> Vec<String> {
680 let mut keys = keys
681 .into_iter()
682 .filter(|key| !APPLIED_CONFIG_KEYS.contains(key))
683 .map(str::to_owned)
684 .collect::<Vec<_>>();
685 keys.sort();
686 keys.dedup();
687 keys
688}
689
690pub fn parse_port_range(value: &str) -> Result<PortRange, PortRangeParseError> {
691 let (start, end) = value
692 .split_once('-')
693 .ok_or(PortRangeParseError::MissingSeparator)?;
694 let start = start
695 .trim()
696 .parse::<u16>()
697 .map_err(|_| PortRangeParseError::InvalidStart(start.trim().to_owned()))?;
698 let end = end
699 .trim()
700 .parse::<u16>()
701 .map_err(|_| PortRangeParseError::InvalidEnd(end.trim().to_owned()))?;
702 let range = PortRange { start, end };
703
704 if range.is_empty() {
705 return Err(PortRangeParseError::Empty(range));
706 }
707
708 Ok(range)
709}
710
711pub fn default_fallback_config() -> String {
712 let skip_ports = DEFAULT_SKIP_PORTS
713 .iter()
714 .map(u16::to_string)
715 .collect::<Vec<_>>()
716 .join(", ");
717
718 format!(
719 "# BindPort fallback config. Project .bindport.* files discovered upward override this file.\n\
720 # This file is optional; BindPort uses built-in defaults when no config exists.\n\
721 default_range = \"{}-{}\"\n\
722 skip_ports = [{}]\n\
723 \n\
724 [dashboard]\n\
725 host = \"127.0.0.1\"\n\
726 port = 27080\n\
727 register_service = false\n\
728 allowed_hosts = [\"localhost\", \"127.0.0.1\"]\n\
729 \n\
730 [dashboard.auth]\n\
731 required = false\n\
732 token_env = \"BINDPORT_DASHBOARD_TOKEN\"\n",
733 DEFAULT_PORT_RANGE.start, DEFAULT_PORT_RANGE.end, skip_ports
734 )
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use std::{
741 process::Command,
742 time::{SystemTime, UNIX_EPOCH},
743 };
744
745 #[test]
746 fn default_range_matches_roadmap() {
747 assert_eq!(DEFAULT_PORT_RANGE.start, 29_000);
748 assert_eq!(DEFAULT_PORT_RANGE.end, 29_999);
749 assert_eq!(DEFAULT_PORT_RANGE.len(), 1_000);
750 }
751
752 #[test]
753 fn inverted_range_is_empty() {
754 let range = PortRange { start: 100, end: 0 };
755
756 assert!(range.is_empty());
757 assert_eq!(range.len(), 0);
758 }
759
760 #[test]
761 fn default_skiplist_marks_reserved_ports() {
762 assert!(is_default_skip_port(29_000));
763 assert!(is_default_skip_port(29_999));
764 assert!(!is_default_skip_port(29_500));
765 }
766
767 #[test]
768 fn config_filenames_preserve_format_precedence() {
769 assert_eq!(
770 CONFIG_FILENAMES,
771 [".bindport.toml", ".bindport.json", ".bindport.yaml"]
772 );
773 }
774
775 #[test]
776 fn parses_config_formats() {
777 let toml = parse_config(
778 ConfigFormat::Toml,
779 "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",
780 )
781 .expect("toml config");
782 let json = parse_config(
783 ConfigFormat::Json,
784 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}"}}]}"#,
785 )
786 .expect("json config");
787 let yaml = parse_config(
788 ConfigFormat::Yaml,
789 "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",
790 )
791 .expect("yaml config");
792
793 assert_eq!(toml, json);
794 assert_eq!(json, yaml);
795 let dashboard = toml.dashboard.as_ref().expect("dashboard config");
796 assert_eq!(dashboard.host.as_deref(), Some("127.0.0.1"));
797 assert_eq!(dashboard.port, Some(27_080));
798 assert_eq!(dashboard.register_service, Some(true));
799 assert_eq!(
800 dashboard.allowed_hosts,
801 Some(vec![String::from("localhost")])
802 );
803 let auth = dashboard.auth.as_ref().expect("dashboard auth");
804 assert_eq!(auth.required, Some(true));
805 assert_eq!(auth.token_env.as_deref(), Some("BINDPORT_DASHBOARD_TOKEN"));
806 let service = toml.service_config("web").expect("service config by name");
807 assert_eq!(
808 service.hostname.as_deref(),
809 Some("{branch}.{project}.localhost")
810 );
811 assert_eq!(
812 service
813 .env
814 .as_ref()
815 .and_then(|env| env.get("NEXT_PUBLIC_BINDPORT_URL"))
816 .map(String::as_str),
817 Some("{route_url}")
818 );
819 assert_eq!(toml.configured_service_name(), Some("web"));
820 }
821
822 #[test]
823 fn reports_unknown_top_level_config_keys() {
824 let keys = unknown_top_level_config_keys(
825 ConfigFormat::Toml,
826 "project = \"demo\"\ndefaultrange = \"29100-29199\"\n[proxy.traefik]\nenabled = true\n",
827 )
828 .expect("unknown keys");
829
830 assert_eq!(keys, ["defaultrange", "proxy"]);
831 }
832
833 #[test]
834 fn normalizes_branch_labels_for_hostnames() {
835 assert_eq!(normalize_branch_label("feature/tree"), "feature-tree");
836 assert_eq!(
837 normalize_branch_label("BUGFIX/JIRA-123_widget"),
838 "bugfix-jira-123-widget"
839 );
840 assert_eq!(normalize_branch_label("!!!"), "branch");
841 }
842
843 #[test]
844 fn identity_sources_follow_precedence() {
845 let cwd = Path::new("/tmp/bindport");
846 let command = [String::from("next")];
847
848 let identity = resolve_identity(IdentitySources {
849 cwd,
850 command: &command,
851 cli_project: None,
852 cli_service: Some("cli-service"),
853 env_project: Some("env-project"),
854 env_service: Some("env-service"),
855 config_project: Some("config-project"),
856 config_service: Some("config-service"),
857 });
858
859 assert_eq!(identity.project, "env-project");
860 assert_eq!(identity.service, "cli-service");
861 }
862
863 #[test]
864 fn config_identity_beats_inference() {
865 let cwd = Path::new("/tmp/bindport");
866 let command = [String::from("next")];
867
868 let identity = resolve_identity(IdentitySources {
869 cwd,
870 command: &command,
871 cli_project: None,
872 cli_service: None,
873 env_project: None,
874 env_service: None,
875 config_project: Some("config-project"),
876 config_service: Some("config-service"),
877 });
878
879 assert_eq!(identity.project, "config-project");
880 assert_eq!(identity.service, "config-service");
881 }
882
883 #[test]
884 fn package_metadata_infers_standalone_identity() {
885 let root = temp_test_dir("package-standalone");
886 fs::write(root.join("package.json"), r#"{"name":"@stutz/hoststamp"}"#)
887 .expect("write package json");
888 let command = [String::from("next")];
889
890 let identity = resolve_identity(IdentitySources {
891 cwd: &root,
892 command: &command,
893 cli_project: None,
894 cli_service: None,
895 env_project: None,
896 env_service: None,
897 config_project: None,
898 config_service: None,
899 });
900
901 assert_eq!(identity.project, "hoststamp");
902 assert_eq!(identity.service, "hoststamp");
903 }
904
905 #[test]
906 fn package_metadata_uses_git_root_project_and_nearest_service() {
907 let root = temp_test_dir("package-monorepo");
908 git(&root, ["init"]);
909 git(&root, ["config", "user.email", "bindport@example.invalid"]);
910 git(&root, ["config", "user.name", "BindPort Test"]);
911 git(&root, ["config", "commit.gpgsign", "false"]);
912 fs::write(root.join("package.json"), r#"{"name":"hoststamp"}"#)
913 .expect("write root package json");
914 let service = root.join("apps").join("web");
915 fs::create_dir_all(&service).expect("service dir");
916 fs::write(service.join("package.json"), r#"{"name":"@hoststamp/web"}"#)
917 .expect("write service package json");
918 fs::write(root.join("README.md"), "test\n").expect("write fixture");
919 git(
920 &root,
921 ["add", "README.md", "package.json", "apps/web/package.json"],
922 );
923 git(&root, ["commit", "-m", "initial"]);
924 let command = [String::from("next")];
925
926 let identity = resolve_identity(IdentitySources {
927 cwd: &service,
928 command: &command,
929 cli_project: None,
930 cli_service: None,
931 env_project: None,
932 env_service: None,
933 config_project: None,
934 config_service: None,
935 });
936
937 assert_eq!(identity.project, "hoststamp");
938 assert_eq!(identity.service, "web");
939 assert!(identity.git.is_some());
940 }
941
942 #[test]
943 fn explicit_identity_beats_package_metadata() {
944 let root = temp_test_dir("package-explicit");
945 fs::write(root.join("package.json"), r#"{"name":"package-project"}"#)
946 .expect("write package json");
947 let command = [String::from("next")];
948
949 let identity = resolve_identity(IdentitySources {
950 cwd: &root,
951 command: &command,
952 cli_project: None,
953 cli_service: Some("cli-service"),
954 env_project: Some("env-project"),
955 env_service: Some("env-service"),
956 config_project: Some("config-project"),
957 config_service: Some("config-service"),
958 });
959
960 assert_eq!(identity.project, "env-project");
961 assert_eq!(identity.service, "cli-service");
962 }
963
964 #[test]
965 fn invalid_package_metadata_falls_back_to_directory_and_command() {
966 let root = temp_test_dir("package-invalid");
967 fs::write(root.join("package.json"), r#"{"name":""}"#).expect("write package json");
968 let command = [String::from("next")];
969
970 let identity = resolve_identity(IdentitySources {
971 cwd: &root,
972 command: &command,
973 cli_project: None,
974 cli_service: None,
975 env_project: None,
976 env_service: None,
977 config_project: None,
978 config_service: None,
979 });
980
981 assert_eq!(
982 identity.project,
983 root.file_name().unwrap().to_str().unwrap()
984 );
985 assert_eq!(identity.service, "next");
986 }
987
988 #[test]
989 fn identity_key_delimits_project_and_service_values() {
990 let cwd = Path::new("/tmp/bindport");
991 let command = [String::from("next")];
992 let first = resolve_identity(IdentitySources {
993 cwd,
994 command: &command,
995 cli_project: Some("a:b"),
996 cli_service: Some("c"),
997 env_project: None,
998 env_service: None,
999 config_project: None,
1000 config_service: None,
1001 });
1002 let second = resolve_identity(IdentitySources {
1003 cwd,
1004 command: &command,
1005 cli_project: Some("a"),
1006 cli_service: Some("b:c"),
1007 env_project: None,
1008 env_service: None,
1009 config_project: None,
1010 config_service: None,
1011 });
1012
1013 assert_ne!(first.identity_key, second.identity_key);
1014 assert!(first.identity_key.starts_with("v1:"));
1015 }
1016
1017 #[test]
1018 fn identity_port_scan_start_is_stable_and_in_range() {
1019 let identity = ServiceIdentity {
1020 project: String::from("bindport"),
1021 service: String::from("web"),
1022 git: None,
1023 identity_key: String::from("v1:test"),
1024 };
1025 let range = PortRange {
1026 start: 29_100,
1027 end: 29_199,
1028 };
1029 let scan_start = identity.port_scan_start(range).expect("scan start");
1030
1031 assert!(range.contains(scan_start));
1032 assert_eq!(identity.port_scan_start(range), Some(scan_start));
1033 assert_eq!(
1034 identity.port_scan_start(PortRange { start: 100, end: 0 }),
1035 None
1036 );
1037 }
1038
1039 #[test]
1040 fn detects_git_worktree_branch_and_commit() {
1041 let root = temp_test_dir("git-identity");
1042 git(&root, ["init"]);
1043 git(&root, ["config", "user.email", "bindport@example.invalid"]);
1044 git(&root, ["config", "user.name", "BindPort Test"]);
1045 git(&root, ["config", "commit.gpgsign", "false"]);
1046 fs::write(root.join("README.md"), "test\n").expect("write fixture");
1047 git(&root, ["add", "README.md"]);
1048 git(&root, ["commit", "-m", "initial"]);
1049 git(&root, ["checkout", "-B", "feature/tree"]);
1050 let nested = root.join("apps").join("web");
1051 fs::create_dir_all(&nested).expect("nested dir");
1052
1053 let identity = detect_git_identity(&nested).expect("git identity");
1054
1055 assert_eq!(identity.worktree_path, root.canonicalize().expect("root"));
1056 assert_eq!(identity.branch, "feature/tree");
1057 assert_eq!(identity.branch_label, "feature-tree");
1058 assert!(!identity.commit.is_empty());
1059 assert!(!identity.worktree_hash.is_empty());
1060 }
1061
1062 #[test]
1063 fn parses_port_range() {
1064 assert_eq!(
1065 parse_port_range("29100-29199").expect("range"),
1066 PortRange {
1067 start: 29_100,
1068 end: 29_199
1069 }
1070 );
1071 assert!(matches!(
1072 parse_port_range("29199-29100"),
1073 Err(PortRangeParseError::Empty(_))
1074 ));
1075 }
1076
1077 fn temp_test_dir(name: &str) -> PathBuf {
1078 let now = SystemTime::now()
1079 .duration_since(UNIX_EPOCH)
1080 .expect("clock")
1081 .as_nanos();
1082 let path =
1083 std::env::temp_dir().join(format!("bindport-core-{name}-{}-{now}", std::process::id()));
1084
1085 fs::create_dir_all(&path).expect("temp dir");
1086 path
1087 }
1088
1089 fn git<const N: usize>(cwd: &Path, args: [&str; N]) {
1090 let output = Command::new("git")
1091 .arg("-C")
1092 .arg(cwd)
1093 .args(args)
1094 .output()
1095 .expect("run git");
1096
1097 assert!(
1098 output.status.success(),
1099 "git failed: {}",
1100 String::from_utf8_lossy(&output.stderr)
1101 );
1102 }
1103}