Skip to main content

bindport_core/
lib.rs

1// SPDX-License-Identifier: MIT
2
3use 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}