1use clap::{Parser, ValueEnum};
4use std::path::PathBuf;
5
6#[derive(Parser, Debug)]
8#[command(name = "flowscope")]
9#[command(about = "Analyze SQL files for data lineage", long_about = None)]
10#[command(version)]
11pub struct Args {
12 #[arg(value_name = "FILES")]
14 pub files: Vec<PathBuf>,
15
16 #[arg(short, long, default_value = "generic", value_enum)]
18 pub dialect: DialectArg,
19
20 #[arg(short, long, default_value = "table", value_enum)]
22 pub format: OutputFormat,
23
24 #[arg(short, long, value_name = "FILE")]
26 pub schema: Option<PathBuf>,
27
28 #[cfg(feature = "metadata-provider")]
31 #[arg(long, value_name = "URL")]
32 pub metadata_url: Option<String>,
33
34 #[cfg(feature = "metadata-provider")]
37 #[arg(long, value_name = "SCHEMA")]
38 pub metadata_schema: Option<String>,
39
40 #[arg(short, long, value_name = "FILE")]
42 pub output: Option<PathBuf>,
43
44 #[arg(long, default_value = "lineage")]
46 pub project_name: String,
47
48 #[arg(long, value_name = "SCHEMA")]
50 pub export_schema: Option<String>,
51
52 #[arg(short, long, default_value = "table", value_enum)]
54 pub view: ViewMode,
55
56 #[arg(long)]
58 pub lint: bool,
59
60 #[arg(long, requires = "lint")]
62 pub fix: bool,
63
64 #[arg(long, requires_all = ["lint", "fix"])]
66 pub fix_only: bool,
67
68 #[arg(long, requires_all = ["lint", "fix"])]
70 pub unsafe_fixes: bool,
71
72 #[arg(long, requires_all = ["lint", "fix"])]
74 pub legacy_ast_fixes: bool,
75
76 #[arg(long, requires = "lint")]
78 pub show_fixes: bool,
79
80 #[arg(long, requires = "lint", value_delimiter = ',')]
82 pub exclude_rules: Vec<String>,
83
84 #[arg(long, requires = "lint", value_name = "JSON")]
87 pub rule_configs: Option<String>,
88
89 #[arg(
91 long,
92 requires = "lint",
93 value_name = "N",
94 value_parser = parse_positive_usize
95 )]
96 pub jobs: Option<usize>,
97
98 #[arg(long, requires = "lint")]
100 pub no_respect_gitignore: bool,
101
102 #[arg(short, long)]
104 pub quiet: bool,
105
106 #[arg(short, long)]
108 pub compact: bool,
109
110 #[cfg(feature = "templating")]
112 #[arg(long, value_enum)]
113 pub template: Option<TemplateArg>,
114
115 #[cfg(feature = "templating")]
117 #[arg(long = "template-var", value_name = "KEY=VALUE")]
118 pub template_vars: Vec<String>,
119
120 #[cfg(feature = "serve")]
122 #[arg(long)]
123 pub serve: bool,
124
125 #[cfg(feature = "serve")]
127 #[arg(long, default_value = "3000")]
128 pub port: u16,
129
130 #[cfg(feature = "serve")]
132 #[arg(long, value_name = "DIR")]
133 pub watch: Vec<PathBuf>,
134
135 #[cfg(feature = "serve")]
137 #[arg(long)]
138 pub open: bool,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
143pub enum DialectArg {
144 Generic,
145 Ansi,
146 Bigquery,
147 Clickhouse,
148 #[value(alias = "sparksql")]
149 Databricks,
150 Duckdb,
151 Hive,
152 Mssql,
153 Mysql,
154 Oracle,
155 Postgres,
156 Redshift,
157 Snowflake,
158 Sqlite,
159}
160
161impl From<DialectArg> for flowscope_core::Dialect {
162 fn from(d: DialectArg) -> Self {
163 match d {
164 DialectArg::Generic => flowscope_core::Dialect::Generic,
165 DialectArg::Ansi => flowscope_core::Dialect::Ansi,
166 DialectArg::Bigquery => flowscope_core::Dialect::Bigquery,
167 DialectArg::Clickhouse => flowscope_core::Dialect::Clickhouse,
168 DialectArg::Databricks => flowscope_core::Dialect::Databricks,
169 DialectArg::Duckdb => flowscope_core::Dialect::Duckdb,
170 DialectArg::Hive => flowscope_core::Dialect::Hive,
171 DialectArg::Mssql => flowscope_core::Dialect::Mssql,
172 DialectArg::Mysql => flowscope_core::Dialect::Mysql,
173 DialectArg::Oracle => flowscope_core::Dialect::Oracle,
174 DialectArg::Postgres => flowscope_core::Dialect::Postgres,
175 DialectArg::Redshift => flowscope_core::Dialect::Redshift,
176 DialectArg::Snowflake => flowscope_core::Dialect::Snowflake,
177 DialectArg::Sqlite => flowscope_core::Dialect::Sqlite,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
184pub enum OutputFormat {
185 Table,
187 Json,
189 Mermaid,
191 Html,
193 Sql,
195 Csv,
197 Xlsx,
199 Duckdb,
201 Dali,
203}
204
205fn parse_positive_usize(value: &str) -> Result<usize, String> {
206 let parsed = value
207 .parse::<usize>()
208 .map_err(|_| format!("invalid value '{value}', expected a positive integer"))?;
209 if parsed == 0 {
210 return Err("must be greater than zero".to_string());
211 }
212 Ok(parsed)
213}
214
215#[cfg(feature = "templating")]
217#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
218pub enum TemplateArg {
219 Jinja,
221 Dbt,
223}
224
225#[cfg(feature = "templating")]
226impl From<TemplateArg> for flowscope_core::TemplateMode {
227 fn from(t: TemplateArg) -> Self {
228 match t {
229 TemplateArg::Jinja => flowscope_core::TemplateMode::Jinja,
230 TemplateArg::Dbt => flowscope_core::TemplateMode::Dbt,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
237pub enum ViewMode {
238 Script,
240 Table,
242 Column,
244 Hybrid,
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_dialect_conversion() {
254 let dialect: flowscope_core::Dialect = DialectArg::Postgres.into();
255 assert_eq!(dialect, flowscope_core::Dialect::Postgres);
256 }
257
258 #[test]
259 fn test_sparksql_dialect_alias_maps_to_databricks() {
260 let args = Args::parse_from(["flowscope", "-d", "sparksql", "test.sql"]);
261 assert_eq!(args.dialect, DialectArg::Databricks);
262 let core_dialect: flowscope_core::Dialect = args.dialect.into();
263 assert_eq!(core_dialect, flowscope_core::Dialect::Databricks);
264 }
265
266 #[test]
267 fn test_parse_minimal_args() {
268 let args = Args::parse_from(["flowscope", "test.sql"]);
269 assert_eq!(args.files.len(), 1);
270 assert_eq!(args.dialect, DialectArg::Generic);
271 assert_eq!(args.format, OutputFormat::Table);
272 assert_eq!(args.project_name, "lineage");
273 assert!(args.export_schema.is_none());
274 }
275
276 #[test]
277 fn test_parse_full_args() {
278 let args = Args::parse_from([
279 "flowscope",
280 "-d",
281 "postgres",
282 "-f",
283 "json",
284 "-s",
285 "schema.sql",
286 "-o",
287 "output.json",
288 "-v",
289 "column",
290 "--quiet",
291 "--compact",
292 "--project-name",
293 "demo",
294 "--export-schema",
295 "lineage",
296 "file1.sql",
297 "file2.sql",
298 ]);
299 assert_eq!(args.dialect, DialectArg::Postgres);
300 assert_eq!(args.format, OutputFormat::Json);
301 assert_eq!(args.schema.unwrap().to_str().unwrap(), "schema.sql");
302 assert_eq!(args.output.unwrap().to_str().unwrap(), "output.json");
303 assert_eq!(args.view, ViewMode::Column);
304 assert_eq!(args.project_name, "demo");
305 assert_eq!(args.export_schema.as_deref(), Some("lineage"));
306 assert!(args.quiet);
307 assert!(args.compact);
308 assert_eq!(args.files.len(), 2);
309 }
310
311 #[test]
312 fn test_lint_flag() {
313 let args = Args::parse_from(["flowscope", "--lint", "test.sql"]);
314 assert!(args.lint);
315 assert!(!args.fix);
316 assert!(!args.fix_only);
317 assert!(!args.unsafe_fixes);
318 assert!(!args.legacy_ast_fixes);
319 assert!(!args.show_fixes);
320 assert!(args.exclude_rules.is_empty());
321 assert!(args.rule_configs.is_none());
322 assert!(args.jobs.is_none());
323 assert!(!args.no_respect_gitignore);
324 }
325
326 #[test]
327 fn test_lint_fix_flag() {
328 let args = Args::parse_from(["flowscope", "--lint", "--fix", "test.sql"]);
329 assert!(args.lint);
330 assert!(args.fix);
331 assert!(!args.fix_only);
332 assert!(!args.unsafe_fixes);
333 assert!(!args.legacy_ast_fixes);
334 assert!(!args.show_fixes);
335 }
336
337 #[test]
338 fn test_fix_only_flag() {
339 let args = Args::parse_from(["flowscope", "--lint", "--fix", "--fix-only", "test.sql"]);
340 assert!(args.lint);
341 assert!(args.fix);
342 assert!(args.fix_only);
343 }
344
345 #[test]
346 fn test_fix_only_requires_lint_and_fix() {
347 let missing_both = Args::try_parse_from(["flowscope", "--fix-only", "test.sql"]);
348 assert!(missing_both.is_err());
349
350 let missing_fix = Args::try_parse_from(["flowscope", "--lint", "--fix-only", "test.sql"]);
351 assert!(missing_fix.is_err());
352 }
353
354 #[test]
355 fn test_fix_requires_lint() {
356 let result = Args::try_parse_from(["flowscope", "--fix", "test.sql"]);
357 assert!(result.is_err());
358 }
359
360 #[test]
361 fn test_unsafe_fixes_flag() {
362 let args = Args::parse_from(["flowscope", "--lint", "--fix", "--unsafe-fixes", "test.sql"]);
363 assert!(args.lint);
364 assert!(args.fix);
365 assert!(args.unsafe_fixes);
366 }
367
368 #[test]
369 fn test_unsafe_fixes_requires_lint_and_fix() {
370 let missing_both = Args::try_parse_from(["flowscope", "--unsafe-fixes", "test.sql"]);
371 assert!(missing_both.is_err());
372
373 let missing_fix =
374 Args::try_parse_from(["flowscope", "--lint", "--unsafe-fixes", "test.sql"]);
375 assert!(missing_fix.is_err());
376 }
377
378 #[test]
379 fn test_legacy_ast_fixes_flag() {
380 let args = Args::parse_from([
381 "flowscope",
382 "--lint",
383 "--fix",
384 "--legacy-ast-fixes",
385 "test.sql",
386 ]);
387 assert!(args.lint);
388 assert!(args.fix);
389 assert!(args.legacy_ast_fixes);
390 }
391
392 #[test]
393 fn test_legacy_ast_fixes_requires_lint_and_fix() {
394 let missing_both = Args::try_parse_from(["flowscope", "--legacy-ast-fixes", "test.sql"]);
395 assert!(missing_both.is_err());
396
397 let missing_fix =
398 Args::try_parse_from(["flowscope", "--lint", "--legacy-ast-fixes", "test.sql"]);
399 assert!(missing_fix.is_err());
400 }
401
402 #[test]
403 fn test_show_fixes_flag() {
404 let args = Args::parse_from(["flowscope", "--lint", "--show-fixes", "test.sql"]);
405 assert!(args.lint);
406 assert!(args.show_fixes);
407 }
408
409 #[test]
410 fn test_show_fixes_requires_lint() {
411 let result = Args::try_parse_from(["flowscope", "--show-fixes", "test.sql"]);
412 assert!(result.is_err());
413 }
414
415 #[test]
416 fn test_lint_exclude_rules() {
417 let args = Args::parse_from([
418 "flowscope",
419 "--lint",
420 "--exclude-rules",
421 "LINT_AM_008,LINT_ST_006",
422 "test.sql",
423 ]);
424 assert!(args.lint);
425 assert_eq!(args.exclude_rules, vec!["LINT_AM_008", "LINT_ST_006"]);
426 }
427
428 #[test]
429 fn test_lint_exclude_rules_repeated() {
430 let args = Args::parse_from([
431 "flowscope",
432 "--lint",
433 "--exclude-rules",
434 "LINT_AM_008",
435 "--exclude-rules",
436 "LINT_ST_006",
437 "test.sql",
438 ]);
439 assert_eq!(args.exclude_rules, vec!["LINT_AM_008", "LINT_ST_006"]);
440 }
441
442 #[test]
443 fn test_lint_exclude_rules_requires_lint() {
444 let result = Args::try_parse_from([
445 "flowscope",
446 "--exclude-rules",
447 "LINT_AM_008,LINT_ST_006",
448 "test.sql",
449 ]);
450 assert!(result.is_err());
451 }
452
453 #[test]
454 fn test_lint_rule_configs_json() {
455 let args = Args::parse_from([
456 "flowscope",
457 "--lint",
458 "--rule-configs",
459 r#"{"structure.subquery":{"forbid_subquery_in":"both"}}"#,
460 "test.sql",
461 ]);
462 assert_eq!(
463 args.rule_configs.as_deref(),
464 Some(r#"{"structure.subquery":{"forbid_subquery_in":"both"}}"#)
465 );
466 }
467
468 #[test]
469 fn test_lint_jobs_flag() {
470 let args = Args::parse_from(["flowscope", "--lint", "--jobs", "4", "test.sql"]);
471 assert_eq!(args.jobs, Some(4));
472 }
473
474 #[test]
475 fn test_lint_jobs_requires_lint() {
476 let result = Args::try_parse_from(["flowscope", "--jobs", "4", "test.sql"]);
477 assert!(result.is_err());
478 }
479
480 #[test]
481 fn test_lint_jobs_must_be_positive() {
482 let result = Args::try_parse_from(["flowscope", "--lint", "--jobs", "0", "test.sql"]);
483 assert!(result.is_err());
484 }
485
486 #[test]
487 fn test_lint_no_respect_gitignore_flag() {
488 let args = Args::parse_from(["flowscope", "--lint", "--no-respect-gitignore", "test.sql"]);
489 assert!(args.no_respect_gitignore);
490 }
491
492 #[test]
493 fn test_lint_no_respect_gitignore_requires_lint() {
494 let result = Args::try_parse_from(["flowscope", "--no-respect-gitignore", "test.sql"]);
495 assert!(result.is_err());
496 }
497
498 #[cfg(feature = "serve")]
499 #[test]
500 fn test_serve_args_defaults() {
501 let args = Args::parse_from(["flowscope", "--serve"]);
502 assert!(args.serve);
503 assert_eq!(args.port, 3000);
504 assert!(args.watch.is_empty());
505 assert!(!args.open);
506 }
507
508 #[cfg(feature = "serve")]
509 #[test]
510 fn test_serve_args_custom_port() {
511 let args = Args::parse_from(["flowscope", "--serve", "--port", "8080"]);
512 assert!(args.serve);
513 assert_eq!(args.port, 8080);
514 }
515
516 #[cfg(feature = "serve")]
517 #[test]
518 fn test_serve_args_watch_dirs() {
519 let args = Args::parse_from([
520 "flowscope",
521 "--serve",
522 "--watch",
523 "./sql",
524 "--watch",
525 "./queries",
526 ]);
527 assert!(args.serve);
528 assert_eq!(args.watch.len(), 2);
529 assert_eq!(args.watch[0].to_str().unwrap(), "./sql");
530 assert_eq!(args.watch[1].to_str().unwrap(), "./queries");
531 }
532
533 #[cfg(feature = "serve")]
534 #[test]
535 fn test_serve_args_open_browser() {
536 let args = Args::parse_from(["flowscope", "--serve", "--open"]);
537 assert!(args.serve);
538 assert!(args.open);
539 }
540
541 #[cfg(feature = "serve")]
542 #[test]
543 fn test_serve_args_full() {
544 let args = Args::parse_from([
545 "flowscope",
546 "--serve",
547 "--port",
548 "9000",
549 "--watch",
550 "./examples",
551 "--open",
552 "-d",
553 "postgres",
554 ]);
555 assert!(args.serve);
556 assert_eq!(args.port, 9000);
557 assert_eq!(args.watch.len(), 1);
558 assert!(args.open);
559 assert_eq!(args.dialect, DialectArg::Postgres);
560 }
561}