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 Postgres,
155 Redshift,
156 Snowflake,
157 Sqlite,
158}
159
160impl From<DialectArg> for flowscope_core::Dialect {
161 fn from(d: DialectArg) -> Self {
162 match d {
163 DialectArg::Generic => flowscope_core::Dialect::Generic,
164 DialectArg::Ansi => flowscope_core::Dialect::Ansi,
165 DialectArg::Bigquery => flowscope_core::Dialect::Bigquery,
166 DialectArg::Clickhouse => flowscope_core::Dialect::Clickhouse,
167 DialectArg::Databricks => flowscope_core::Dialect::Databricks,
168 DialectArg::Duckdb => flowscope_core::Dialect::Duckdb,
169 DialectArg::Hive => flowscope_core::Dialect::Hive,
170 DialectArg::Mssql => flowscope_core::Dialect::Mssql,
171 DialectArg::Mysql => flowscope_core::Dialect::Mysql,
172 DialectArg::Postgres => flowscope_core::Dialect::Postgres,
173 DialectArg::Redshift => flowscope_core::Dialect::Redshift,
174 DialectArg::Snowflake => flowscope_core::Dialect::Snowflake,
175 DialectArg::Sqlite => flowscope_core::Dialect::Sqlite,
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
182pub enum OutputFormat {
183 Table,
185 Json,
187 Mermaid,
189 Html,
191 Sql,
193 Csv,
195 Xlsx,
197 Duckdb,
199}
200
201fn parse_positive_usize(value: &str) -> Result<usize, String> {
202 let parsed = value
203 .parse::<usize>()
204 .map_err(|_| format!("invalid value '{value}', expected a positive integer"))?;
205 if parsed == 0 {
206 return Err("must be greater than zero".to_string());
207 }
208 Ok(parsed)
209}
210
211#[cfg(feature = "templating")]
213#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
214pub enum TemplateArg {
215 Jinja,
217 Dbt,
219}
220
221#[cfg(feature = "templating")]
222impl From<TemplateArg> for flowscope_core::TemplateMode {
223 fn from(t: TemplateArg) -> Self {
224 match t {
225 TemplateArg::Jinja => flowscope_core::TemplateMode::Jinja,
226 TemplateArg::Dbt => flowscope_core::TemplateMode::Dbt,
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
233pub enum ViewMode {
234 Script,
236 Table,
238 Column,
240 Hybrid,
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_dialect_conversion() {
250 let dialect: flowscope_core::Dialect = DialectArg::Postgres.into();
251 assert_eq!(dialect, flowscope_core::Dialect::Postgres);
252 }
253
254 #[test]
255 fn test_sparksql_dialect_alias_maps_to_databricks() {
256 let args = Args::parse_from(["flowscope", "-d", "sparksql", "test.sql"]);
257 assert_eq!(args.dialect, DialectArg::Databricks);
258 let core_dialect: flowscope_core::Dialect = args.dialect.into();
259 assert_eq!(core_dialect, flowscope_core::Dialect::Databricks);
260 }
261
262 #[test]
263 fn test_parse_minimal_args() {
264 let args = Args::parse_from(["flowscope", "test.sql"]);
265 assert_eq!(args.files.len(), 1);
266 assert_eq!(args.dialect, DialectArg::Generic);
267 assert_eq!(args.format, OutputFormat::Table);
268 assert_eq!(args.project_name, "lineage");
269 assert!(args.export_schema.is_none());
270 }
271
272 #[test]
273 fn test_parse_full_args() {
274 let args = Args::parse_from([
275 "flowscope",
276 "-d",
277 "postgres",
278 "-f",
279 "json",
280 "-s",
281 "schema.sql",
282 "-o",
283 "output.json",
284 "-v",
285 "column",
286 "--quiet",
287 "--compact",
288 "--project-name",
289 "demo",
290 "--export-schema",
291 "lineage",
292 "file1.sql",
293 "file2.sql",
294 ]);
295 assert_eq!(args.dialect, DialectArg::Postgres);
296 assert_eq!(args.format, OutputFormat::Json);
297 assert_eq!(args.schema.unwrap().to_str().unwrap(), "schema.sql");
298 assert_eq!(args.output.unwrap().to_str().unwrap(), "output.json");
299 assert_eq!(args.view, ViewMode::Column);
300 assert_eq!(args.project_name, "demo");
301 assert_eq!(args.export_schema.as_deref(), Some("lineage"));
302 assert!(args.quiet);
303 assert!(args.compact);
304 assert_eq!(args.files.len(), 2);
305 }
306
307 #[test]
308 fn test_lint_flag() {
309 let args = Args::parse_from(["flowscope", "--lint", "test.sql"]);
310 assert!(args.lint);
311 assert!(!args.fix);
312 assert!(!args.fix_only);
313 assert!(!args.unsafe_fixes);
314 assert!(!args.legacy_ast_fixes);
315 assert!(!args.show_fixes);
316 assert!(args.exclude_rules.is_empty());
317 assert!(args.rule_configs.is_none());
318 assert!(args.jobs.is_none());
319 assert!(!args.no_respect_gitignore);
320 }
321
322 #[test]
323 fn test_lint_fix_flag() {
324 let args = Args::parse_from(["flowscope", "--lint", "--fix", "test.sql"]);
325 assert!(args.lint);
326 assert!(args.fix);
327 assert!(!args.fix_only);
328 assert!(!args.unsafe_fixes);
329 assert!(!args.legacy_ast_fixes);
330 assert!(!args.show_fixes);
331 }
332
333 #[test]
334 fn test_fix_only_flag() {
335 let args = Args::parse_from(["flowscope", "--lint", "--fix", "--fix-only", "test.sql"]);
336 assert!(args.lint);
337 assert!(args.fix);
338 assert!(args.fix_only);
339 }
340
341 #[test]
342 fn test_fix_only_requires_lint_and_fix() {
343 let missing_both = Args::try_parse_from(["flowscope", "--fix-only", "test.sql"]);
344 assert!(missing_both.is_err());
345
346 let missing_fix = Args::try_parse_from(["flowscope", "--lint", "--fix-only", "test.sql"]);
347 assert!(missing_fix.is_err());
348 }
349
350 #[test]
351 fn test_fix_requires_lint() {
352 let result = Args::try_parse_from(["flowscope", "--fix", "test.sql"]);
353 assert!(result.is_err());
354 }
355
356 #[test]
357 fn test_unsafe_fixes_flag() {
358 let args = Args::parse_from(["flowscope", "--lint", "--fix", "--unsafe-fixes", "test.sql"]);
359 assert!(args.lint);
360 assert!(args.fix);
361 assert!(args.unsafe_fixes);
362 }
363
364 #[test]
365 fn test_unsafe_fixes_requires_lint_and_fix() {
366 let missing_both = Args::try_parse_from(["flowscope", "--unsafe-fixes", "test.sql"]);
367 assert!(missing_both.is_err());
368
369 let missing_fix =
370 Args::try_parse_from(["flowscope", "--lint", "--unsafe-fixes", "test.sql"]);
371 assert!(missing_fix.is_err());
372 }
373
374 #[test]
375 fn test_legacy_ast_fixes_flag() {
376 let args = Args::parse_from([
377 "flowscope",
378 "--lint",
379 "--fix",
380 "--legacy-ast-fixes",
381 "test.sql",
382 ]);
383 assert!(args.lint);
384 assert!(args.fix);
385 assert!(args.legacy_ast_fixes);
386 }
387
388 #[test]
389 fn test_legacy_ast_fixes_requires_lint_and_fix() {
390 let missing_both = Args::try_parse_from(["flowscope", "--legacy-ast-fixes", "test.sql"]);
391 assert!(missing_both.is_err());
392
393 let missing_fix =
394 Args::try_parse_from(["flowscope", "--lint", "--legacy-ast-fixes", "test.sql"]);
395 assert!(missing_fix.is_err());
396 }
397
398 #[test]
399 fn test_show_fixes_flag() {
400 let args = Args::parse_from(["flowscope", "--lint", "--show-fixes", "test.sql"]);
401 assert!(args.lint);
402 assert!(args.show_fixes);
403 }
404
405 #[test]
406 fn test_show_fixes_requires_lint() {
407 let result = Args::try_parse_from(["flowscope", "--show-fixes", "test.sql"]);
408 assert!(result.is_err());
409 }
410
411 #[test]
412 fn test_lint_exclude_rules() {
413 let args = Args::parse_from([
414 "flowscope",
415 "--lint",
416 "--exclude-rules",
417 "LINT_AM_008,LINT_ST_006",
418 "test.sql",
419 ]);
420 assert!(args.lint);
421 assert_eq!(args.exclude_rules, vec!["LINT_AM_008", "LINT_ST_006"]);
422 }
423
424 #[test]
425 fn test_lint_exclude_rules_repeated() {
426 let args = Args::parse_from([
427 "flowscope",
428 "--lint",
429 "--exclude-rules",
430 "LINT_AM_008",
431 "--exclude-rules",
432 "LINT_ST_006",
433 "test.sql",
434 ]);
435 assert_eq!(args.exclude_rules, vec!["LINT_AM_008", "LINT_ST_006"]);
436 }
437
438 #[test]
439 fn test_lint_exclude_rules_requires_lint() {
440 let result = Args::try_parse_from([
441 "flowscope",
442 "--exclude-rules",
443 "LINT_AM_008,LINT_ST_006",
444 "test.sql",
445 ]);
446 assert!(result.is_err());
447 }
448
449 #[test]
450 fn test_lint_rule_configs_json() {
451 let args = Args::parse_from([
452 "flowscope",
453 "--lint",
454 "--rule-configs",
455 r#"{"structure.subquery":{"forbid_subquery_in":"both"}}"#,
456 "test.sql",
457 ]);
458 assert_eq!(
459 args.rule_configs.as_deref(),
460 Some(r#"{"structure.subquery":{"forbid_subquery_in":"both"}}"#)
461 );
462 }
463
464 #[test]
465 fn test_lint_jobs_flag() {
466 let args = Args::parse_from(["flowscope", "--lint", "--jobs", "4", "test.sql"]);
467 assert_eq!(args.jobs, Some(4));
468 }
469
470 #[test]
471 fn test_lint_jobs_requires_lint() {
472 let result = Args::try_parse_from(["flowscope", "--jobs", "4", "test.sql"]);
473 assert!(result.is_err());
474 }
475
476 #[test]
477 fn test_lint_jobs_must_be_positive() {
478 let result = Args::try_parse_from(["flowscope", "--lint", "--jobs", "0", "test.sql"]);
479 assert!(result.is_err());
480 }
481
482 #[test]
483 fn test_lint_no_respect_gitignore_flag() {
484 let args = Args::parse_from(["flowscope", "--lint", "--no-respect-gitignore", "test.sql"]);
485 assert!(args.no_respect_gitignore);
486 }
487
488 #[test]
489 fn test_lint_no_respect_gitignore_requires_lint() {
490 let result = Args::try_parse_from(["flowscope", "--no-respect-gitignore", "test.sql"]);
491 assert!(result.is_err());
492 }
493
494 #[cfg(feature = "serve")]
495 #[test]
496 fn test_serve_args_defaults() {
497 let args = Args::parse_from(["flowscope", "--serve"]);
498 assert!(args.serve);
499 assert_eq!(args.port, 3000);
500 assert!(args.watch.is_empty());
501 assert!(!args.open);
502 }
503
504 #[cfg(feature = "serve")]
505 #[test]
506 fn test_serve_args_custom_port() {
507 let args = Args::parse_from(["flowscope", "--serve", "--port", "8080"]);
508 assert!(args.serve);
509 assert_eq!(args.port, 8080);
510 }
511
512 #[cfg(feature = "serve")]
513 #[test]
514 fn test_serve_args_watch_dirs() {
515 let args = Args::parse_from([
516 "flowscope",
517 "--serve",
518 "--watch",
519 "./sql",
520 "--watch",
521 "./queries",
522 ]);
523 assert!(args.serve);
524 assert_eq!(args.watch.len(), 2);
525 assert_eq!(args.watch[0].to_str().unwrap(), "./sql");
526 assert_eq!(args.watch[1].to_str().unwrap(), "./queries");
527 }
528
529 #[cfg(feature = "serve")]
530 #[test]
531 fn test_serve_args_open_browser() {
532 let args = Args::parse_from(["flowscope", "--serve", "--open"]);
533 assert!(args.serve);
534 assert!(args.open);
535 }
536
537 #[cfg(feature = "serve")]
538 #[test]
539 fn test_serve_args_full() {
540 let args = Args::parse_from([
541 "flowscope",
542 "--serve",
543 "--port",
544 "9000",
545 "--watch",
546 "./examples",
547 "--open",
548 "-d",
549 "postgres",
550 ]);
551 assert!(args.serve);
552 assert_eq!(args.port, 9000);
553 assert_eq!(args.watch.len(), 1);
554 assert!(args.open);
555 assert_eq!(args.dialect, DialectArg::Postgres);
556 }
557}