@@ -10,7 +10,37 @@ use std::path::Path;
1010pub struct ParsedApp {
1111 pub name : String ,
1212 pub port : u16 ,
13+ pub mode : AppMode ,
1314 pub services : Vec < ParsedService > ,
15+ pub nodes : Vec < ParsedNode > ,
16+ pub routes : Vec < ParsedRoute > ,
17+ }
18+
19+ #[ derive( Debug , PartialEq ) ]
20+ pub enum AppMode {
21+ Server ,
22+ Pipeline ,
23+ }
24+
25+ /// Parsed pipeline node (HttpSink/HttpSource).
26+ #[ derive( Debug ) ]
27+ pub struct ParsedNode {
28+ pub name : String ,
29+ pub node_type : String , // http_sink, http_source, transform
30+ pub port : Option < u16 > ,
31+ pub path : Option < String > ,
32+ pub url : Option < String > ,
33+ pub format : Option < String > ,
34+ pub json_tap : Option < String > ,
35+ pub dialect : Option < String > ,
36+ }
37+
38+ /// Parsed pipeline route.
39+ #[ derive( Debug ) ]
40+ pub struct ParsedRoute {
41+ pub from : String ,
42+ pub to : String ,
43+ pub mode : String ,
1444}
1545
1646/// Parsed ServiceProcess.
@@ -33,33 +63,84 @@ pub fn parse_rust_source(path: &Path) -> Result<ParsedApp, String> {
3363 let source = std:: fs:: read_to_string ( path)
3464 . map_err ( |e| format ! ( "Failed to read {}: {}" , path. display( ) , e) ) ?;
3565
36- let name = extract_app_name ( & source) . unwrap_or_else ( || "app" . to_string ( ) ) ;
37- let port = extract_port ( & source) . unwrap_or ( 8080 ) ;
38- let services = extract_services ( & source) ;
66+ // Resolve constants: const NAME: type = "value";
67+ let constants = extract_constants ( & source) ;
68+ let resolved = resolve_constants ( & source, & constants) ;
69+
70+ let services = extract_services ( & resolved) ;
71+ let nodes = extract_pipeline_nodes ( & resolved) ;
72+ let routes = extract_pipeline_routes ( & resolved) ;
3973
40- Ok ( ParsedApp { name, port, services } )
74+ let is_pipeline = !nodes. is_empty ( ) || source. contains ( "vil_workflow!" ) ;
75+ let mode = if is_pipeline { AppMode :: Pipeline } else { AppMode :: Server } ;
76+
77+ let name = if is_pipeline {
78+ extract_workflow_name ( & resolved)
79+ . or_else ( || extract_app_name ( & resolved) )
80+ . unwrap_or_else ( || "app" . to_string ( ) )
81+ } else {
82+ extract_app_name ( & resolved) . unwrap_or_else ( || "app" . to_string ( ) )
83+ } ;
84+
85+ let port = extract_port ( & resolved)
86+ . or_else ( || nodes. iter ( ) . find ( |n| n. port . is_some ( ) ) . and_then ( |n| n. port ) )
87+ . unwrap_or ( 8080 ) ;
88+
89+ Ok ( ParsedApp { name, port, mode, services, nodes, routes } )
4190}
4291
4392/// Generate YAML manifest from parsed app.
4493pub fn to_manifest_yaml ( app : & ParsedApp ) -> String {
4594 let mut lines = Vec :: new ( ) ;
46- lines. push ( format ! ( "vil_version: \" 6.0.0\" " ) ) ;
95+ lines. push ( "vil_version: \" 6.0.0\" " . to_string ( ) ) ;
4796 lines. push ( format ! ( "name: {}" , app. name) ) ;
4897 lines. push ( format ! ( "port: {}" , app. port) ) ;
49- lines. push ( "mode: server" . to_string ( ) ) ;
50-
51- if !app. services . is_empty ( ) {
52- lines. push ( String :: new ( ) ) ;
53- lines. push ( "services:" . to_string ( ) ) ;
54- for svc in & app. services {
55- lines. push ( format ! ( " - name: {}" , svc. name) ) ;
56- lines. push ( format ! ( " prefix: /api/{}" , svc. name) ) ;
57- if !svc. endpoints . is_empty ( ) {
58- lines. push ( " endpoints:" . to_string ( ) ) ;
59- for ep in & svc. endpoints {
60- lines. push ( format ! ( " - method: {}" , ep. method) ) ;
61- lines. push ( format ! ( " path: {}" , ep. path) ) ;
62- lines. push ( format ! ( " handler: {}" , ep. handler) ) ;
98+ lines. push ( format ! ( "token: shm" ) ) ;
99+
100+ match app. mode {
101+ AppMode :: Pipeline => {
102+ // Nodes
103+ if !app. nodes . is_empty ( ) {
104+ lines. push ( String :: new ( ) ) ;
105+ lines. push ( "nodes:" . to_string ( ) ) ;
106+ for node in & app. nodes {
107+ lines. push ( format ! ( " {}:" , node. name) ) ;
108+ lines. push ( format ! ( " type: {}" , node. node_type) ) ;
109+ if let Some ( p) = node. port { lines. push ( format ! ( " port: {}" , p) ) ; }
110+ if let Some ( ref p) = node. path { lines. push ( format ! ( " path: \" {}\" " , p) ) ; }
111+ if let Some ( ref u) = node. url { lines. push ( format ! ( " url: \" {}\" " , u) ) ; }
112+ if let Some ( ref f) = node. format { lines. push ( format ! ( " format: {}" , f) ) ; }
113+ if let Some ( ref j) = node. json_tap { lines. push ( format ! ( " json_tap: \" {}\" " , j) ) ; }
114+ if let Some ( ref d) = node. dialect { lines. push ( format ! ( " dialect: {}" , d) ) ; }
115+ }
116+ }
117+ // Routes
118+ if !app. routes . is_empty ( ) {
119+ lines. push ( String :: new ( ) ) ;
120+ lines. push ( "routes:" . to_string ( ) ) ;
121+ for r in & app. routes {
122+ lines. push ( format ! ( " - from: {}" , r. from) ) ;
123+ lines. push ( format ! ( " to: {}" , r. to) ) ;
124+ lines. push ( format ! ( " mode: {}" , r. mode) ) ;
125+ }
126+ }
127+ }
128+ AppMode :: Server => {
129+ lines. push ( "mode: server" . to_string ( ) ) ;
130+ if !app. services . is_empty ( ) {
131+ lines. push ( String :: new ( ) ) ;
132+ lines. push ( "services:" . to_string ( ) ) ;
133+ for svc in & app. services {
134+ lines. push ( format ! ( " - name: {}" , svc. name) ) ;
135+ lines. push ( format ! ( " prefix: /api/{}" , svc. name) ) ;
136+ if !svc. endpoints . is_empty ( ) {
137+ lines. push ( " endpoints:" . to_string ( ) ) ;
138+ for ep in & svc. endpoints {
139+ lines. push ( format ! ( " - method: {}" , ep. method) ) ;
140+ lines. push ( format ! ( " path: {}" , ep. path) ) ;
141+ lines. push ( format ! ( " handler: {}" , ep. handler) ) ;
142+ }
143+ }
63144 }
64145 }
65146 }
@@ -203,6 +284,181 @@ fn extract_quoted(s: &str) -> Option<String> {
203284 Some ( s[ start..end] . to_string ( ) )
204285}
205286
287+ // ── Pipeline Parsing ──
288+
289+ /// Extract constants: `const NAME: type = value;` → HashMap
290+ fn extract_constants ( source : & str ) -> std:: collections:: HashMap < String , String > {
291+ let mut map = std:: collections:: HashMap :: new ( ) ;
292+ for line in source. lines ( ) {
293+ let trimmed = line. trim ( ) ;
294+ if trimmed. starts_with ( "const " ) {
295+ // const NAME: type = "value";
296+ let parts: Vec < & str > = trimmed. splitn ( 2 , '=' ) . collect ( ) ;
297+ if parts. len ( ) == 2 {
298+ let name_part = parts[ 0 ] . trim ( ) ;
299+ let name = name_part. split ( ':' ) . next ( ) . unwrap_or ( "" ) . trim ( )
300+ . strip_prefix ( "const " ) . unwrap_or ( "" ) . trim ( ) ;
301+ let val = parts[ 1 ] . trim ( ) . trim_end_matches ( ';' ) . trim ( ) ;
302+ // Store quoted and numeric values
303+ if let Some ( q) = extract_quoted ( val) {
304+ map. insert ( name. to_string ( ) , q) ;
305+ } else if let Ok ( n) = val. parse :: < u64 > ( ) {
306+ map. insert ( name. to_string ( ) , n. to_string ( ) ) ;
307+ }
308+ }
309+ }
310+ }
311+ map
312+ }
313+
314+ /// Resolve constant references in source text.
315+ fn resolve_constants ( source : & str , constants : & std:: collections:: HashMap < String , String > ) -> String {
316+ let mut result = source. to_string ( ) ;
317+ for ( name, value) in constants {
318+ // Replace uses like .port(WEBHOOK_PORT) → .port(3080)
319+ result = result. replace ( name, & format ! ( "\" {}\" " , value) ) ;
320+ }
321+ result
322+ }
323+
324+ /// Extract workflow name from `vil_workflow! { name: "..." }`
325+ fn extract_workflow_name ( source : & str ) -> Option < String > {
326+ for line in source. lines ( ) {
327+ let trimmed = line. trim ( ) ;
328+ if trimmed. starts_with ( "name:" ) && !trimmed. contains ( "vil_version" ) {
329+ return extract_quoted ( trimmed) ;
330+ }
331+ }
332+ None
333+ }
334+
335+ /// Extract pipeline nodes from HttpSinkBuilder/HttpSourceBuilder patterns.
336+ fn extract_pipeline_nodes ( source : & str ) -> Vec < ParsedNode > {
337+ let mut nodes = Vec :: new ( ) ;
338+ let mut current_node: Option < ParsedNode > = None ;
339+
340+ for line in source. lines ( ) {
341+ let trimmed = line. trim ( ) ;
342+
343+ // HttpSinkBuilder::new("Name")
344+ if trimmed. contains ( "HttpSinkBuilder::new(" ) {
345+ if let Some ( node) = current_node. take ( ) { nodes. push ( node) ; }
346+ let name = extract_quoted ( trimmed) . unwrap_or_else ( || "http_sink" . to_string ( ) ) ;
347+ current_node = Some ( ParsedNode {
348+ name : to_snake ( & name) ,
349+ node_type : "http_sink" . to_string ( ) ,
350+ port : None , path : None , url : None , format : None , json_tap : None , dialect : None ,
351+ } ) ;
352+ }
353+
354+ // HttpSourceBuilder::new("Name")
355+ if trimmed. contains ( "HttpSourceBuilder::new(" ) {
356+ if let Some ( node) = current_node. take ( ) { nodes. push ( node) ; }
357+ let name = extract_quoted ( trimmed) . unwrap_or_else ( || "http_source" . to_string ( ) ) ;
358+ current_node = Some ( ParsedNode {
359+ name : to_snake ( & name) ,
360+ node_type : "http_source" . to_string ( ) ,
361+ port : None , path : None , url : None , format : None , json_tap : None , dialect : None ,
362+ } ) ;
363+ }
364+
365+ // Chained builder methods
366+ if let Some ( ref mut node) = current_node {
367+ if trimmed. starts_with ( ".port(" ) {
368+ if let Some ( q) = extract_quoted ( trimmed) {
369+ node. port = q. parse ( ) . ok ( ) ;
370+ }
371+ }
372+ if trimmed. starts_with ( ".path(" ) {
373+ node. path = extract_quoted ( trimmed) ;
374+ }
375+ if trimmed. starts_with ( ".url(" ) {
376+ node. url = extract_quoted ( trimmed) ;
377+ }
378+ if trimmed. starts_with ( ".format(" ) {
379+ if trimmed. contains ( "SSE" ) { node. format = Some ( "sse" . to_string ( ) ) ; }
380+ else if trimmed. contains ( "JSON" ) { node. format = Some ( "json" . to_string ( ) ) ; }
381+ else if trimmed. contains ( "NDJSON" ) { node. format = Some ( "ndjson" . to_string ( ) ) ; }
382+ }
383+ if trimmed. starts_with ( ".json_tap(" ) {
384+ node. json_tap = extract_quoted ( trimmed) ;
385+ }
386+ if trimmed. starts_with ( ".dialect(" ) {
387+ if trimmed. contains ( "OpenAi" ) { node. dialect = Some ( "openai" . to_string ( ) ) ; }
388+ else if trimmed. contains ( "Anthropic" ) { node. dialect = Some ( "anthropic" . to_string ( ) ) ; }
389+ else if trimmed. contains ( "Ollama" ) { node. dialect = Some ( "ollama" . to_string ( ) ) ; }
390+ }
391+ }
392+ }
393+
394+ if let Some ( node) = current_node { nodes. push ( node) ; }
395+ nodes
396+ }
397+
398+ /// Extract pipeline routes from `vil_workflow! { routes: [...] }`.
399+ fn extract_pipeline_routes ( source : & str ) -> Vec < ParsedRoute > {
400+ let mut routes = Vec :: new ( ) ;
401+ let mut in_routes = false ;
402+
403+ for line in source. lines ( ) {
404+ let trimmed = line. trim ( ) ;
405+
406+ if trimmed. starts_with ( "routes:" ) && trimmed. contains ( '[' ) {
407+ in_routes = true ;
408+ continue ;
409+ }
410+
411+ if in_routes {
412+ if trimmed. contains ( ']' ) { in_routes = false ; continue ; }
413+
414+ // Pattern: sink_builder.trigger_out -> source_builder.trigger_in (LoanWrite),
415+ if trimmed. contains ( "->" ) && trimmed. contains ( '(' ) {
416+ let parts: Vec < & str > = trimmed. split ( "->" ) . collect ( ) ;
417+ if parts. len ( ) == 2 {
418+ let from = parts[ 0 ] . trim ( ) . replace ( "_builder" , "" ) ;
419+ let to_mode = parts[ 1 ] . trim ( ) . trim_end_matches ( ',' ) ;
420+
421+ // Split "source.port (Mode)"
422+ let to_parts: Vec < & str > = to_mode. split ( '(' ) . collect ( ) ;
423+ let to = to_parts[ 0 ] . trim ( ) . replace ( "_builder" , "" ) ;
424+ let mode = if to_parts. len ( ) > 1 {
425+ to_parts[ 1 ] . trim ( ) . trim_end_matches ( ')' ) . trim ( ) . to_string ( )
426+ } else {
427+ "LoanWrite" . to_string ( )
428+ } ;
429+
430+ // Convert snake_case builder names to node names
431+ let from_name = from. split ( '.' ) . next ( ) . unwrap_or ( & from) ;
432+ let from_port = from. split ( '.' ) . nth ( 1 ) . unwrap_or ( "data_out" ) ;
433+ let to_name = to. split ( '.' ) . next ( ) . unwrap_or ( & to) ;
434+ let to_port = to. split ( '.' ) . nth ( 1 ) . unwrap_or ( "data_in" ) ;
435+
436+ routes. push ( ParsedRoute {
437+ from : format ! ( "{}.{}" , to_snake( from_name) , from_port) ,
438+ to : format ! ( "{}.{}" , to_snake( to_name) , to_port) ,
439+ mode,
440+ } ) ;
441+ }
442+ }
443+ }
444+ }
445+
446+ routes
447+ }
448+
449+ /// Convert PascalCase/camelCase to snake_case.
450+ fn to_snake ( s : & str ) -> String {
451+ let trimmed = s. trim ( ) ;
452+ let mut result = String :: new ( ) ;
453+ for ( i, ch) in trimmed. chars ( ) . enumerate ( ) {
454+ if ch. is_uppercase ( ) && i > 0 {
455+ result. push ( '_' ) ;
456+ }
457+ result. push ( ch. to_lowercase ( ) . next ( ) . unwrap_or ( ch) ) ;
458+ }
459+ result
460+ }
461+
206462// ── Tests ──
207463
208464#[ cfg( test) ]
@@ -270,6 +526,9 @@ VilApp::new("my-server")
270526 let app = ParsedApp {
271527 name : "test-app" . to_string ( ) ,
272528 port : 8080 ,
529+ mode : AppMode :: Server ,
530+ nodes : vec ! [ ] ,
531+ routes : vec ! [ ] ,
273532 services : vec ! [ ParsedService {
274533 name: "tasks" . to_string( ) ,
275534 endpoints: vec![
0 commit comments