Skip to main content

bindport_core/
lib.rs

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