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