Skip to main content

bindport_core/
lib.rs

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