Skip to content

Commit e8f357a

Browse files
feat: pipeline parser extension — HttpSink/Source/vil_workflow YAML export
manifest_export.rs extended: - Parse HttpSinkBuilder::new() → node type=http_sink with port, path - Parse HttpSourceBuilder::new() → node type=http_source with url, format, json_tap, dialect - Parse vil_workflow! { routes: [...] } → route entries with from/to/mode - Resolve const values (WEBHOOK_PORT, SSE_URL, etc.) - Auto-detect pipeline vs server mode - extract_constants() + resolve_constants() for const substitution Golden manifests regenerated for all 9 examples: - Pipeline: 001 (27 lines), 005 (24), 007 (24), 016 (27) - Server: 003 (19), 004 (28), 010 (19), 605 (37) - Minimal: 027 (5 — VilServer, no ServiceProcess) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c058e39 commit e8f357a

10 files changed

Lines changed: 381 additions & 31 deletions

File tree

crates/vil_cli_server/src/orm/manifest_export.rs

Lines changed: 278 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,37 @@ use std::path::Path;
1010
pub 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.
4493
pub 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![
Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
11
vil_version: "6.0.0"
2-
name: app
3-
port: 8080
4-
mode: server
2+
name: DecomposedPipeline
3+
port: 3080
4+
token: shm
5+
6+
nodes:
7+
webhook_trigger:
8+
type: http_sink
9+
port: 3080
10+
path: "/trigger"
11+
sse_inference:
12+
type: http_source
13+
url: "http://127.0.0.1:4545/v1/chat/completions"
14+
format: sse
15+
json_tap: "choices[0].delta.content"
16+
dialect: openai
17+
18+
routes:
19+
- from: sink.trigger_out
20+
to: source.trigger_in
21+
mode: LoanWrite
22+
- from: source.response_data_out
23+
to: sink.response_data_in
24+
mode: LoanWrite
25+
- from: source.response_ctrl_out
26+
to: sink.response_ctrl_in
27+
mode: Copy

examples/003-basic-hello-server/manifest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
vil_version: "6.0.0"
22
name: vil-basic-hello-server
33
port: 8080
4+
token: shm
45
mode: server
56

67
services:

examples/004-basic-rest-crud/manifest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
vil_version: "6.0.0"
22
name: crud-vilorm
33
port: 8080
4+
token: shm
45
mode: server
56

67
services:

0 commit comments

Comments
 (0)