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