@@ -4,6 +4,8 @@ use std::collections::HashMap;
44use std:: path:: { Path , PathBuf } ;
55use std:: process:: Command ;
66
7+ use crate :: sync:: Framework ;
8+
79// ── Registry types ───────────────────────────────────────────────────────────
810
911#[ derive( Serialize , Deserialize , Debug , Default ) ]
@@ -21,15 +23,24 @@ fn default_base_port() -> u16 {
2123#[ derive( Serialize , Deserialize , Debug , Clone ) ]
2224pub struct PortAllocation {
2325 pub port : u16 ,
26+ /// Number of ports in the allocated range (backward compat: defaults to 1).
27+ #[ serde( default = "default_port_count" ) ]
28+ pub port_count : u16 ,
2429 pub branch : String ,
2530 pub db_name : String ,
2631 pub compose_project : String ,
2732 pub worktree_path : String ,
2833 pub allocated_at : String ,
2934}
3035
36+ fn default_port_count ( ) -> u16 {
37+ 1
38+ }
39+
3140pub struct IsolationConfig {
3241 pub port : u16 ,
42+ pub port_end : u16 ,
43+ pub port_count : u16 ,
3344 pub db_name : String ,
3445 pub compose_project : String ,
3546}
@@ -79,30 +90,57 @@ pub fn branch_to_slug(branch: &str) -> String {
7990
8091// ── Port allocation ──────────────────────────────────────────────────────────
8192
82- fn next_available_port ( registry : & PortRegistry ) -> u16 {
83- let used: std:: collections:: HashSet < u16 > =
84- registry. allocations . values ( ) . map ( |a| a. port ) . collect ( ) ;
93+ /// Allocate a contiguous block of `range_size` ports that doesn't overlap any
94+ /// existing allocation. Aligns to `range_size` boundaries for clean ranges.
95+ fn next_available_port_range ( registry : & PortRegistry , range_size : u16 ) -> u16 {
96+ let occupied: Vec < ( u16 , u16 ) > = registry
97+ . allocations
98+ . values ( )
99+ . map ( |a| ( a. port , a. port + a. port_count ) )
100+ . collect ( ) ;
101+
85102 let base = if registry. base_port == 0 { 3000 } else { registry. base_port } ;
86- let mut port = base;
87- while used. contains ( & port) {
88- port += 1 ;
103+ let mut candidate = base;
104+
105+ // Align to range_size boundaries
106+ if range_size > 1 && candidate % range_size != 0 {
107+ candidate = candidate + range_size - ( candidate % range_size) ;
108+ }
109+
110+ loop {
111+ let candidate_end = candidate + range_size;
112+ let overlaps = occupied
113+ . iter ( )
114+ . any ( |& ( start, end) | candidate < end && candidate_end > start) ;
115+ if !overlaps {
116+ return candidate;
117+ }
118+ candidate += range_size;
119+ if candidate > 60000 {
120+ return candidate;
121+ }
89122 }
90- port
91123}
92124
93125// ── Main API ─────────────────────────────────────────────────────────────────
94126
95- /// Allocate a port, compute derived names, update registry, write .env.local.
96- pub fn setup_isolation ( branch : & str , wt_path : & Path ) -> Result < IsolationConfig > {
127+ /// Allocate a port range, compute derived names, update registry, write .env.local.
128+ pub fn setup_isolation (
129+ branch : & str ,
130+ wt_path : & Path ,
131+ range_size : u16 ,
132+ framework : Framework ,
133+ ) -> Result < IsolationConfig > {
97134 let mut registry = load_registry ( ) ;
98135 let slug = branch_to_slug ( branch) ;
99136
100137 let alloc = if let Some ( existing) = registry. allocations . get ( & slug) {
101138 existing. clone ( )
102139 } else {
103- let port = next_available_port ( & registry) ;
140+ let port = next_available_port_range ( & registry, range_size ) ;
104141 let alloc = PortAllocation {
105142 port,
143+ port_count : range_size,
106144 branch : branch. to_string ( ) ,
107145 db_name : slug. clone ( ) ,
108146 compose_project : slug. clone ( ) ,
@@ -114,10 +152,12 @@ pub fn setup_isolation(branch: &str, wt_path: &Path) -> Result<IsolationConfig>
114152 alloc
115153 } ;
116154
117- write_env_local ( wt_path, & alloc) ?;
155+ write_env_local ( wt_path, & alloc, framework ) ?;
118156
119157 Ok ( IsolationConfig {
120158 port : alloc. port ,
159+ port_end : alloc. port + alloc. port_count - 1 ,
160+ port_count : alloc. port_count ,
121161 db_name : alloc. db_name . clone ( ) ,
122162 compose_project : alloc. compose_project . clone ( ) ,
123163 } )
@@ -157,19 +197,46 @@ pub fn get_allocation(branch: &str) -> Option<PortAllocation> {
157197
158198// ── .env.local writer ────────────────────────────────────────────────────────
159199
160- fn write_env_local ( wt_path : & Path , alloc : & PortAllocation ) -> Result < ( ) > {
161- let content = format ! (
162- "# Generated by workz --isolated — do not edit manually\n \
163- PORT={port}\n \
164- DB_NAME={db}\n \
165- DATABASE_URL=postgres://localhost/{db}\n \
166- COMPOSE_PROJECT_NAME={compose}\n \
167- REDIS_URL=redis://localhost:{redis}\n ",
168- port = alloc. port,
169- db = alloc. db_name,
170- compose = alloc. compose_project,
171- redis = alloc. port + 1000 ,
172- ) ;
200+ fn write_env_local ( wt_path : & Path , alloc : & PortAllocation , framework : Framework ) -> Result < ( ) > {
201+ let port = alloc. port ;
202+ let port_end = alloc. port + alloc. port_count - 1 ;
203+
204+ let mut lines = vec ! [
205+ "# Generated by workz --isolated — do not edit manually" . to_string( ) ,
206+ format!( "PORT={}" , port) ,
207+ ] ;
208+
209+ // Only write PORT_END when we have a range (not a single port)
210+ if alloc. port_count > 1 {
211+ lines. push ( format ! ( "PORT_END={}" , port_end) ) ;
212+ }
213+
214+ // Framework-specific port vars
215+ match framework {
216+ Framework :: SpringBoot => {
217+ lines. push ( format ! ( "SERVER_PORT={}" , port) ) ;
218+ }
219+ Framework :: Flask => {
220+ lines. push ( format ! ( "FLASK_RUN_PORT={}" , port) ) ;
221+ }
222+ Framework :: FastApi => {
223+ lines. push ( format ! ( "UVICORN_PORT={}" , port) ) ;
224+ }
225+ Framework :: Vite => {
226+ lines. push ( format ! ( "VITE_PORT={}" , port) ) ;
227+ }
228+ _ => { }
229+ }
230+
231+ lines. push ( format ! ( "DB_NAME={}" , alloc. db_name) ) ;
232+ lines. push ( format ! ( "DATABASE_URL=postgres://localhost/{}" , alloc. db_name) ) ;
233+ lines. push ( format ! ( "COMPOSE_PROJECT_NAME={}" , alloc. compose_project) ) ;
234+
235+ // Redis on port+1 (within the allocated range, not port+1000)
236+ let redis_port = if alloc. port_count > 1 { port + 1 } else { port + 1000 } ;
237+ lines. push ( format ! ( "REDIS_URL=redis://localhost:{}" , redis_port) ) ;
238+
239+ let content = lines. join ( "\n " ) + "\n " ;
173240 std:: fs:: write ( wt_path. join ( ".env.local" ) , content) ?;
174241 Ok ( ( ) )
175242}
@@ -228,4 +295,57 @@ mod tests {
228295 // 2024-03-04T12:00:00Z = 1709553600
229296 assert_eq ! ( unix_secs_to_rfc3339( 1709553600 ) , "2024-03-04T12:00:00Z" ) ;
230297 }
298+
299+ #[ test]
300+ fn range_allocation_no_overlap ( ) {
301+ let mut registry = PortRegistry { base_port : 3000 , allocations : HashMap :: new ( ) } ;
302+
303+ let port1 = next_available_port_range ( & registry, 10 ) ;
304+ assert_eq ! ( port1, 3000 ) ;
305+
306+ registry. allocations . insert ( "first" . into ( ) , PortAllocation {
307+ port : 3000 , port_count : 10 ,
308+ branch : "a" . into ( ) , db_name : "a" . into ( ) ,
309+ compose_project : "a" . into ( ) , worktree_path : "/tmp/a" . into ( ) ,
310+ allocated_at : "2024-01-01T00:00:00Z" . into ( ) ,
311+ } ) ;
312+
313+ let port2 = next_available_port_range ( & registry, 10 ) ;
314+ assert_eq ! ( port2, 3010 ) ;
315+ }
316+
317+ #[ test]
318+ fn range_allocation_backward_compat ( ) {
319+ let mut registry = PortRegistry { base_port : 3000 , allocations : HashMap :: new ( ) } ;
320+ registry. allocations . insert ( "old" . into ( ) , PortAllocation {
321+ port : 3000 , port_count : 1 ,
322+ branch : "old" . into ( ) , db_name : "old" . into ( ) ,
323+ compose_project : "old" . into ( ) , worktree_path : "/tmp/old" . into ( ) ,
324+ allocated_at : "2024-01-01T00:00:00Z" . into ( ) ,
325+ } ) ;
326+
327+ let port = next_available_port_range ( & registry, 10 ) ;
328+ assert_eq ! ( port, 3010 ) ;
329+ }
330+
331+ #[ test]
332+ fn range_allocation_gap_filling ( ) {
333+ let mut registry = PortRegistry { base_port : 3000 , allocations : HashMap :: new ( ) } ;
334+
335+ registry. allocations . insert ( "first" . into ( ) , PortAllocation {
336+ port : 3000 , port_count : 10 ,
337+ branch : "a" . into ( ) , db_name : "a" . into ( ) ,
338+ compose_project : "a" . into ( ) , worktree_path : "/tmp/a" . into ( ) ,
339+ allocated_at : "2024-01-01T00:00:00Z" . into ( ) ,
340+ } ) ;
341+ registry. allocations . insert ( "third" . into ( ) , PortAllocation {
342+ port : 3020 , port_count : 10 ,
343+ branch : "c" . into ( ) , db_name : "c" . into ( ) ,
344+ compose_project : "c" . into ( ) , worktree_path : "/tmp/c" . into ( ) ,
345+ allocated_at : "2024-01-01T00:00:00Z" . into ( ) ,
346+ } ) ;
347+
348+ let port = next_available_port_range ( & registry, 10 ) ;
349+ assert_eq ! ( port, 3010 ) ;
350+ }
231351}
0 commit comments