1use std::{
2 collections::{HashMap, HashSet},
3 fmt::Write as _,
4 path::PathBuf,
5};
6
7use clap::Parser;
8use itertools::Itertools;
9use vector_lib::{config::OutputId, id::ComponentKey};
10
11use crate::config::{
12 self,
13 dot_graph::{EdgeAttributes, GraphConfig},
14};
15
16#[derive(Parser, Debug)]
17#[command(rename_all = "kebab-case")]
18pub struct Opts {
19 #[arg(
24 id = "config",
25 short,
26 long,
27 env = "VECTOR_CONFIG",
28 value_delimiter(',')
29 )]
30 paths: Vec<PathBuf>,
31
32 #[arg(id = "config-toml", long, value_delimiter(','))]
34 paths_toml: Vec<PathBuf>,
35
36 #[arg(id = "config-json", long, value_delimiter(','))]
38 paths_json: Vec<PathBuf>,
39
40 #[arg(id = "config-yaml", long, value_delimiter(','))]
42 paths_yaml: Vec<PathBuf>,
43
44 #[arg(
49 id = "config-dir",
50 short = 'C',
51 long,
52 env = "VECTOR_CONFIG_DIR",
53 value_delimiter(',')
54 )]
55 pub config_dirs: Vec<PathBuf>,
56
57 #[arg(id = "format", long, default_value = "dot")]
62 pub format: OutputFormat,
63
64 #[arg(
67 long,
68 env = "VECTOR_DANGEROUSLY_ALLOW_ENV_VAR_INTERPOLATION",
69 default_value = "false"
70 )]
71 pub dangerously_allow_env_var_interpolation: bool,
72}
73
74#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
75pub enum OutputFormat {
76 Dot,
77 Mermaid,
78}
79
80impl Opts {
81 fn paths_with_formats(&self) -> Vec<config::ConfigPath> {
82 config::merge_path_lists(vec![
83 (&self.paths, None),
84 (&self.paths_toml, Some(config::Format::Toml)),
85 (&self.paths_json, Some(config::Format::Json)),
86 (&self.paths_yaml, Some(config::Format::Yaml)),
87 ])
88 .map(|(path, hint)| config::ConfigPath::File(path, hint))
89 .chain(
90 self.config_dirs
91 .iter()
92 .map(|dir| config::ConfigPath::Dir(dir.to_path_buf())),
93 )
94 .collect()
95 }
96}
97
98fn node_attributes_to_string(attributes: &HashMap<String, String>, default_shape: &str) -> String {
99 let mut attrs = attributes.clone();
100 if !attrs.contains_key("shape") {
101 attrs.insert("shape".to_string(), default_shape.to_string());
102 }
103 attrs.iter().map(|(k, v)| format!("{k}=\"{v}\"")).join(" ")
104}
105
106fn edge_attributes_to_string(attributes: &EdgeAttributes, default_label: Option<&str>) -> String {
107 let mut attrs = attributes.0.clone();
108 if let Some(default_label) = default_label
109 && !attrs.contains_key("label")
110 {
111 attrs.insert("label".to_string(), default_label.to_string());
112 }
113 attrs.iter().map(|(k, v)| format!("{k}=\"{v}\"")).join(" ")
114}
115
116pub(crate) fn cmd(opts: &Opts) -> exitcode::ExitCode {
117 let paths = opts.paths_with_formats();
118 let paths = match config::process_paths(&paths) {
119 Some(paths) => paths,
120 None => return exitcode::CONFIG,
121 };
122
123 let config = match config::load_from_paths(&paths) {
124 Ok(config) => config,
125 Err(errs) => {
126 #[allow(clippy::print_stderr)]
127 for err in errs {
128 eprintln!("{err}");
129 }
130 return exitcode::CONFIG;
131 }
132 };
133
134 let format = opts.format;
135 match format {
136 OutputFormat::Dot => render_dot(config),
137 OutputFormat::Mermaid => render_mermaid(config),
138 }
139}
140
141fn render_dot(config: config::Config) -> exitcode::ExitCode {
142 let mut dot = String::from("digraph {\n");
143
144 let mut written_tables = HashSet::<ComponentKey>::new();
145
146 for (id, table) in config
147 .enrichment_tables
148 .iter()
149 .filter_map(|(key, table)| table.as_source(key))
150 {
151 writeln!(
152 dot,
153 " \"{}\" [{}]",
154 id,
155 node_attributes_to_string(&table.graph.node_attributes, "cylinder")
156 )
157 .expect("write to String never fails");
158 written_tables.insert(id);
159 }
160
161 for (id, table) in config
162 .enrichment_tables
163 .iter()
164 .filter_map(|(key, table)| table.as_sink(key))
165 {
166 if !written_tables.contains(&id) {
167 writeln!(
168 dot,
169 " \"{}\" [{}]",
170 id,
171 node_attributes_to_string(&table.graph.node_attributes, "cylinder")
172 )
173 .expect("write to String never fails");
174 }
175
176 for input in table.inputs.iter() {
177 render_dot_edge(&mut dot, &id, input, &table.graph);
178 }
179 }
180
181 for (id, source) in config.sources() {
182 writeln!(
183 dot,
184 " \"{}\" [{}]",
185 id,
186 node_attributes_to_string(&source.graph.node_attributes, "trapezium")
187 )
188 .expect("write to String never fails");
189 }
190
191 for (id, transform) in config.transforms() {
192 writeln!(
193 dot,
194 " \"{}\" [{}]",
195 id,
196 node_attributes_to_string(&transform.graph.node_attributes, "diamond")
197 )
198 .expect("write to String never fails");
199
200 for input in transform.inputs.iter() {
201 render_dot_edge(&mut dot, id, input, &transform.graph);
202 }
203 }
204
205 for (id, sink) in config.sinks() {
206 writeln!(
207 dot,
208 " \"{}\" [{}]",
209 id,
210 node_attributes_to_string(&sink.graph.node_attributes, "invtrapezium")
211 )
212 .expect("write to String never fails");
213
214 for input in &sink.inputs {
215 render_dot_edge(&mut dot, id, input, &sink.graph);
216 }
217 }
218
219 dot += "}";
220
221 #[allow(clippy::print_stdout)]
222 {
223 println!("{dot}");
224 }
225
226 exitcode::OK
227}
228
229fn render_dot_edge(into: &mut String, id: &ComponentKey, input: &OutputId, graph: &GraphConfig) {
230 let edge_attributes = graph
231 .edge_attributes
232 .get(&input.to_string())
233 .or_else(|| graph.edge_attributes.get(&input.component.to_string()));
234 if let Some(port) = &input.port {
235 writeln!(
236 into,
237 " \"{}\" -> \"{id}\" [{}]",
238 input.component,
239 edge_attributes_to_string(
240 edge_attributes.unwrap_or(&EdgeAttributes::default()),
241 Some(port)
242 )
243 )
244 .expect("write to String never fails");
245 } else if let Some(edge_attributes) = edge_attributes {
246 writeln!(
247 into,
248 " \"{input}\" -> \"{id}\" [{}]",
249 edge_attributes_to_string(edge_attributes, None)
250 )
251 .expect("write to String never fails");
252 } else {
253 writeln!(into, " \"{input}\" -> \"{id}\"").expect("write to String never fails");
254 }
255}
256
257fn render_mermaid(config: config::Config) -> exitcode::ExitCode {
258 let mut mermaid = String::from("flowchart TD;\n");
259
260 writeln!(mermaid, "\n %% Enrichment tables").unwrap();
261 let mut written_tables = HashSet::<ComponentKey>::new();
262
263 for (id, _) in config
264 .enrichment_tables
265 .iter()
266 .filter_map(|(key, table)| table.as_source(key))
267 {
268 writeln!(mermaid, " {id}[({id})]").unwrap();
269 written_tables.insert(id);
270 }
271
272 for (id, table) in config
273 .enrichment_tables
274 .iter()
275 .filter_map(|(key, table)| table.as_sink(key))
276 {
277 if !written_tables.contains(&id) {
278 writeln!(mermaid, " {id}[({id})]").unwrap();
279 }
280
281 for input in table.inputs.iter() {
282 if let Some(port) = &input.port {
283 writeln!(mermaid, " {0} -->|{port}| {id}", input.component).unwrap();
284 } else {
285 writeln!(mermaid, " {0} --> {id}", input.component).unwrap();
286 }
287 }
288 }
289
290 writeln!(mermaid, "\n %% Sources").unwrap();
291 for (id, _) in config.sources() {
292 writeln!(mermaid, " {id}[/{id}/]").unwrap();
293 }
294
295 writeln!(mermaid, "\n %% Transforms").unwrap();
296 for (id, transform) in config.transforms() {
297 writeln!(mermaid, " {id}{{{id}}}").unwrap();
298
299 for input in transform.inputs.iter() {
300 if let Some(port) = &input.port {
301 writeln!(mermaid, " {0} -->|{port}| {id}", input.component).unwrap();
302 } else {
303 writeln!(mermaid, " {0} --> {id}", input.component).unwrap();
304 }
305 }
306 }
307
308 writeln!(mermaid, "\n %% Sinks").unwrap();
309 for (id, sink) in config.sinks() {
310 writeln!(mermaid, " {id}[\\{id}\\]").unwrap();
311
312 for input in &sink.inputs {
313 if let Some(port) = &input.port {
314 writeln!(mermaid, " {0} -->|{port}| {id}", input.component).unwrap();
315 } else {
316 writeln!(mermaid, " {0} --> {id}", input.component).unwrap();
317 }
318 }
319 }
320
321 #[allow(clippy::print_stdout)]
322 {
323 println!("{mermaid}");
324 }
325
326 exitcode::OK
327}