@@ -56,6 +56,28 @@ pub struct DockerfileInfo {
5656 pub instruction_count : usize ,
5757}
5858
59+ /// Dockerfile discovery result for deployment wizard
60+ ///
61+ /// Provides deployment-focused metadata about a Dockerfile including
62+ /// build context path, suggested service name, and port configuration.
63+ #[ derive( Debug , Clone , Serialize , Deserialize , PartialEq ) ]
64+ pub struct DiscoveredDockerfile {
65+ /// Absolute path to the Dockerfile
66+ pub path : PathBuf ,
67+ /// Relative path from project root to Dockerfile directory (build context)
68+ pub build_context : String ,
69+ /// Suggested service name based on directory structure
70+ pub suggested_service_name : String ,
71+ /// Suggested port for deployment (from EXPOSE or default)
72+ pub suggested_port : Option < u16 > ,
73+ /// Base image from Dockerfile
74+ pub base_image : Option < String > ,
75+ /// Whether this is a multi-stage build
76+ pub is_multistage : bool ,
77+ /// Environment type (dev, prod, staging) from filename
78+ pub environment : Option < String > ,
79+ }
80+
5981/// Information about a Docker Compose file
6082#[ derive( Debug , Clone , Serialize , Deserialize , PartialEq ) ]
6183pub struct ComposeFileInfo {
@@ -1237,6 +1259,199 @@ fn analyze_environments(
12371259 environments. into_values ( ) . collect ( )
12381260}
12391261
1262+ // =============================================================================
1263+ // Dockerfile Discovery for Deployment Wizard
1264+ // =============================================================================
1265+
1266+ /// Suggests a service name based on Dockerfile path and project structure.
1267+ ///
1268+ /// Uses the parent directory name if not at project root, otherwise uses
1269+ /// the project root's directory name. The name is sanitized to be lowercase
1270+ /// with hyphens (suitable for Kubernetes service names).
1271+ fn suggest_service_name ( dockerfile_path : & Path , project_root : & Path ) -> String {
1272+ // Get parent directory of Dockerfile
1273+ let dockerfile_dir = dockerfile_path. parent ( ) . unwrap_or ( dockerfile_path) ;
1274+
1275+ // Determine which directory name to use
1276+ let name = if dockerfile_dir == project_root {
1277+ // Dockerfile is in project root - use project root's directory name
1278+ project_root
1279+ . file_name ( )
1280+ . and_then ( |n| n. to_str ( ) )
1281+ . unwrap_or ( "app" )
1282+ } else {
1283+ // Use the immediate parent directory name
1284+ dockerfile_dir
1285+ . file_name ( )
1286+ . and_then ( |n| n. to_str ( ) )
1287+ . unwrap_or ( "app" )
1288+ } ;
1289+
1290+ // Sanitize: lowercase, replace underscores/spaces with hyphens, remove non-alphanumeric
1291+ sanitize_service_name ( name)
1292+ }
1293+
1294+ /// Sanitizes a string to be a valid Kubernetes service name.
1295+ /// Lowercase, alphanumeric with hyphens, no leading/trailing hyphens.
1296+ fn sanitize_service_name ( name : & str ) -> String {
1297+ let sanitized: String = name
1298+ . to_lowercase ( )
1299+ . chars ( )
1300+ . map ( |c| {
1301+ if c. is_ascii_alphanumeric ( ) {
1302+ c
1303+ } else {
1304+ '-'
1305+ }
1306+ } )
1307+ . collect ( ) ;
1308+
1309+ // Remove consecutive hyphens and trim hyphens from ends
1310+ let mut result = String :: new ( ) ;
1311+ let mut prev_hyphen = true ; // Start true to skip leading hyphens
1312+
1313+ for c in sanitized. chars ( ) {
1314+ if c == '-' {
1315+ if !prev_hyphen {
1316+ result. push ( c) ;
1317+ prev_hyphen = true ;
1318+ }
1319+ } else {
1320+ result. push ( c) ;
1321+ prev_hyphen = false ;
1322+ }
1323+ }
1324+
1325+ // Remove trailing hyphen
1326+ if result. ends_with ( '-' ) {
1327+ result. pop ( ) ;
1328+ }
1329+
1330+ if result. is_empty ( ) {
1331+ "app" . to_string ( )
1332+ } else {
1333+ result
1334+ }
1335+ }
1336+
1337+ /// Computes build context path relative to project root.
1338+ ///
1339+ /// Returns the relative path from project root to the Dockerfile's directory,
1340+ /// suitable for use as a Docker build context path.
1341+ fn compute_build_context ( dockerfile_path : & Path , project_root : & Path ) -> String {
1342+ let dockerfile_dir = dockerfile_path. parent ( ) . unwrap_or ( dockerfile_path) ;
1343+
1344+ // Try to get relative path from project root to dockerfile directory
1345+ if let Ok ( relative) = dockerfile_dir. strip_prefix ( project_root) {
1346+ let path_str = relative. to_string_lossy ( ) . to_string ( ) ;
1347+ if path_str. is_empty ( ) {
1348+ "." . to_string ( )
1349+ } else {
1350+ path_str
1351+ }
1352+ } else {
1353+ // Fallback: use "." if we can't compute relative path
1354+ "." . to_string ( )
1355+ }
1356+ }
1357+
1358+ /// Infers default port based on base image.
1359+ ///
1360+ /// Returns a common default port for well-known base images.
1361+ fn infer_default_port ( base_image : & Option < String > ) -> Option < u16 > {
1362+ let image = base_image. as_ref ( ) ?;
1363+ let image_lower = image. to_lowercase ( ) ;
1364+
1365+ // Extract image name without registry/tag
1366+ let image_name = image_lower
1367+ . split ( '/' )
1368+ . last ( )
1369+ . unwrap_or ( & image_lower)
1370+ . split ( ':' )
1371+ . next ( )
1372+ . unwrap_or ( & image_lower) ;
1373+
1374+ match image_name {
1375+ // Node.js
1376+ s if s. starts_with ( "node" ) => Some ( 3000 ) ,
1377+ // Python web frameworks
1378+ s if s. contains ( "python" ) => Some ( 8000 ) ,
1379+ s if s. contains ( "flask" ) => Some ( 5000 ) ,
1380+ s if s. contains ( "django" ) => Some ( 8000 ) ,
1381+ s if s. contains ( "fastapi" ) => Some ( 8000 ) ,
1382+ // Go
1383+ s if s. starts_with ( "golang" ) || s. starts_with ( "go" ) => Some ( 8080 ) ,
1384+ // Rust
1385+ s if s. starts_with ( "rust" ) => Some ( 8080 ) ,
1386+ // Web servers
1387+ s if s. starts_with ( "nginx" ) => Some ( 80 ) ,
1388+ s if s. starts_with ( "httpd" ) || s. starts_with ( "apache" ) => Some ( 80 ) ,
1389+ s if s. starts_with ( "caddy" ) => Some ( 80 ) ,
1390+ // Java
1391+ s if s. contains ( "openjdk" ) || s. contains ( "java" ) => Some ( 8080 ) ,
1392+ s if s. contains ( "tomcat" ) => Some ( 8080 ) ,
1393+ s if s. contains ( "spring" ) => Some ( 8080 ) ,
1394+ // Ruby
1395+ s if s. starts_with ( "ruby" ) => Some ( 3000 ) ,
1396+ s if s. contains ( "rails" ) => Some ( 3000 ) ,
1397+ // PHP
1398+ s if s. starts_with ( "php" ) => Some ( 80 ) ,
1399+ // .NET
1400+ s if s. contains ( "dotnet" ) || s. contains ( "aspnet" ) => Some ( 80 ) ,
1401+ // Elixir/Phoenix
1402+ s if s. contains ( "elixir" ) || s. contains ( "phoenix" ) => Some ( 4000 ) ,
1403+ // Default: no inference
1404+ _ => None ,
1405+ }
1406+ }
1407+
1408+ /// Discovers Dockerfiles in a project and returns deployment-focused metadata.
1409+ ///
1410+ /// This function finds all Dockerfiles in the project, parses them, and returns
1411+ /// deployment-relevant information including build context paths, suggested
1412+ /// service names, and port configurations.
1413+ ///
1414+ /// # Arguments
1415+ ///
1416+ /// * `project_root` - The root directory of the project to analyze
1417+ ///
1418+ /// # Returns
1419+ ///
1420+ /// A vector of `DiscoveredDockerfile` structs, one for each Dockerfile found
1421+ pub fn discover_dockerfiles_for_deployment (
1422+ project_root : & Path ,
1423+ ) -> Result < Vec < DiscoveredDockerfile > > {
1424+ let dockerfiles = find_dockerfiles ( project_root) ?;
1425+
1426+ let discovered: Vec < DiscoveredDockerfile > = dockerfiles
1427+ . into_iter ( )
1428+ . filter_map ( |path| {
1429+ let info = parse_dockerfile ( & path) . ok ( ) ?;
1430+ let build_context = compute_build_context ( & path, project_root) ;
1431+ let suggested_name = suggest_service_name ( & path, project_root) ;
1432+
1433+ // Get port from EXPOSE instruction or infer from base image
1434+ let suggested_port = info
1435+ . exposed_ports
1436+ . first ( )
1437+ . copied ( )
1438+ . or_else ( || infer_default_port ( & info. base_image ) ) ;
1439+
1440+ Some ( DiscoveredDockerfile {
1441+ path,
1442+ build_context,
1443+ suggested_service_name : suggested_name,
1444+ suggested_port,
1445+ base_image : info. base_image ,
1446+ is_multistage : info. is_multistage ,
1447+ environment : info. environment ,
1448+ } )
1449+ } )
1450+ . collect ( ) ;
1451+
1452+ Ok ( discovered)
1453+ }
1454+
12401455#[ cfg( test) ]
12411456mod tests {
12421457 use super :: * ;
@@ -1279,4 +1494,147 @@ mod tests {
12791494 None
12801495 ) ;
12811496 }
1497+
1498+ // =============================================================================
1499+ // Dockerfile Discovery Tests
1500+ // =============================================================================
1501+
1502+ #[ test]
1503+ fn test_suggest_service_name_from_subdirectory ( ) {
1504+ let path = PathBuf :: from ( "/project/services/api/Dockerfile" ) ;
1505+ let root = PathBuf :: from ( "/project" ) ;
1506+ assert_eq ! ( suggest_service_name( & path, & root) , "api" ) ;
1507+ }
1508+
1509+ #[ test]
1510+ fn test_suggest_service_name_from_root ( ) {
1511+ let path = PathBuf :: from ( "/project/Dockerfile" ) ;
1512+ let root = PathBuf :: from ( "/project" ) ;
1513+ assert_eq ! ( suggest_service_name( & path, & root) , "project" ) ;
1514+ }
1515+
1516+ #[ test]
1517+ fn test_suggest_service_name_nested ( ) {
1518+ let path = PathBuf :: from ( "/myapp/apps/web-frontend/Dockerfile" ) ;
1519+ let root = PathBuf :: from ( "/myapp" ) ;
1520+ assert_eq ! ( suggest_service_name( & path, & root) , "web-frontend" ) ;
1521+ }
1522+
1523+ #[ test]
1524+ fn test_suggest_service_name_sanitizes ( ) {
1525+ // Underscores become hyphens
1526+ let path = PathBuf :: from ( "/project/my_service_api/Dockerfile" ) ;
1527+ let root = PathBuf :: from ( "/project" ) ;
1528+ assert_eq ! ( suggest_service_name( & path, & root) , "my-service-api" ) ;
1529+ }
1530+
1531+ #[ test]
1532+ fn test_sanitize_service_name ( ) {
1533+ assert_eq ! ( sanitize_service_name( "My_Service" ) , "my-service" ) ;
1534+ assert_eq ! ( sanitize_service_name( "api-v2" ) , "api-v2" ) ;
1535+ assert_eq ! ( sanitize_service_name( "__leading__" ) , "leading" ) ;
1536+ assert_eq ! ( sanitize_service_name( "trailing--" ) , "trailing" ) ;
1537+ assert_eq ! ( sanitize_service_name( "multi---hyphens" ) , "multi-hyphens" ) ;
1538+ assert_eq ! ( sanitize_service_name( "special@#chars!" ) , "special-chars" ) ;
1539+ assert_eq ! ( sanitize_service_name( "" ) , "app" ) ; // Empty defaults to "app"
1540+ }
1541+
1542+ #[ test]
1543+ fn test_compute_build_context_subdirectory ( ) {
1544+ let path = PathBuf :: from ( "/project/services/api/Dockerfile" ) ;
1545+ let root = PathBuf :: from ( "/project" ) ;
1546+ assert_eq ! ( compute_build_context( & path, & root) , "services/api" ) ;
1547+ }
1548+
1549+ #[ test]
1550+ fn test_compute_build_context_root ( ) {
1551+ let path = PathBuf :: from ( "/project/Dockerfile" ) ;
1552+ let root = PathBuf :: from ( "/project" ) ;
1553+ assert_eq ! ( compute_build_context( & path, & root) , "." ) ;
1554+ }
1555+
1556+ #[ test]
1557+ fn test_compute_build_context_deep_nested ( ) {
1558+ let path = PathBuf :: from ( "/myapp/packages/frontend/apps/web/Dockerfile" ) ;
1559+ let root = PathBuf :: from ( "/myapp" ) ;
1560+ assert_eq ! (
1561+ compute_build_context( & path, & root) ,
1562+ "packages/frontend/apps/web"
1563+ ) ;
1564+ }
1565+
1566+ #[ test]
1567+ fn test_infer_default_port_node ( ) {
1568+ assert_eq ! ( infer_default_port( & Some ( "node:18" . to_string( ) ) ) , Some ( 3000 ) ) ;
1569+ assert_eq ! (
1570+ infer_default_port( & Some ( "node:18-alpine" . to_string( ) ) ) ,
1571+ Some ( 3000 )
1572+ ) ;
1573+ }
1574+
1575+ #[ test]
1576+ fn test_infer_default_port_nginx ( ) {
1577+ assert_eq ! (
1578+ infer_default_port( & Some ( "nginx:latest" . to_string( ) ) ) ,
1579+ Some ( 80 )
1580+ ) ;
1581+ assert_eq ! (
1582+ infer_default_port( & Some ( "nginx:1.25-alpine" . to_string( ) ) ) ,
1583+ Some ( 80 )
1584+ ) ;
1585+ }
1586+
1587+ #[ test]
1588+ fn test_infer_default_port_python ( ) {
1589+ assert_eq ! (
1590+ infer_default_port( & Some ( "python:3.11" . to_string( ) ) ) ,
1591+ Some ( 8000 )
1592+ ) ;
1593+ }
1594+
1595+ #[ test]
1596+ fn test_infer_default_port_go ( ) {
1597+ assert_eq ! (
1598+ infer_default_port( & Some ( "golang:1.21" . to_string( ) ) ) ,
1599+ Some ( 8080 )
1600+ ) ;
1601+ }
1602+
1603+ #[ test]
1604+ fn test_infer_default_port_java ( ) {
1605+ assert_eq ! (
1606+ infer_default_port( & Some ( "openjdk:17" . to_string( ) ) ) ,
1607+ Some ( 8080 )
1608+ ) ;
1609+ }
1610+
1611+ #[ test]
1612+ fn test_infer_default_port_ruby ( ) {
1613+ assert_eq ! (
1614+ infer_default_port( & Some ( "ruby:3.2" . to_string( ) ) ) ,
1615+ Some ( 3000 )
1616+ ) ;
1617+ }
1618+
1619+ #[ test]
1620+ fn test_infer_default_port_with_registry ( ) {
1621+ // Should handle images with registry prefix
1622+ assert_eq ! (
1623+ infer_default_port( & Some ( "gcr.io/my-project/node:18" . to_string( ) ) ) ,
1624+ Some ( 3000 )
1625+ ) ;
1626+ assert_eq ! (
1627+ infer_default_port( & Some ( "docker.io/library/nginx:latest" . to_string( ) ) ) ,
1628+ Some ( 80 )
1629+ ) ;
1630+ }
1631+
1632+ #[ test]
1633+ fn test_infer_default_port_unknown ( ) {
1634+ assert_eq ! (
1635+ infer_default_port( & Some ( "custom-base:latest" . to_string( ) ) ) ,
1636+ None
1637+ ) ;
1638+ assert_eq ! ( infer_default_port( & None ) , None ) ;
1639+ }
12821640}
0 commit comments