Skip to main content

flowscope_cli/
cli.rs

1//! CLI argument parsing using clap.
2
3use clap::{Parser, ValueEnum};
4use std::path::PathBuf;
5
6/// FlowScope - SQL lineage analyzer
7#[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    /// SQL files to analyze (reads from stdin if none provided; --lint also accepts directories)
13    #[arg(value_name = "FILES")]
14    pub files: Vec<PathBuf>,
15
16    /// SQL dialect
17    #[arg(short, long, default_value = "generic", value_enum)]
18    pub dialect: DialectArg,
19
20    /// Output format
21    #[arg(short, long, default_value = "table", value_enum)]
22    pub format: OutputFormat,
23
24    /// Schema DDL file for table/column resolution
25    #[arg(short, long, value_name = "FILE")]
26    pub schema: Option<PathBuf>,
27
28    /// Database connection URL for live schema introspection
29    /// (e.g., postgres://user:pass@host/db, mysql://..., sqlite://...)
30    #[cfg(feature = "metadata-provider")]
31    #[arg(long, value_name = "URL")]
32    pub metadata_url: Option<String>,
33
34    /// Schema name to filter when using --metadata-url
35    /// (e.g., 'public' for PostgreSQL, database name for MySQL)
36    #[cfg(feature = "metadata-provider")]
37    #[arg(long, value_name = "SCHEMA")]
38    pub metadata_schema: Option<String>,
39
40    /// Output file (defaults to stdout)
41    #[arg(short, long, value_name = "FILE")]
42    pub output: Option<PathBuf>,
43
44    /// Project name used for default export filenames
45    #[arg(long, default_value = "lineage")]
46    pub project_name: String,
47
48    /// Schema name to prefix DuckDB SQL export
49    #[arg(long, value_name = "SCHEMA")]
50    pub export_schema: Option<String>,
51
52    /// Graph detail level for mermaid output
53    #[arg(short, long, default_value = "table", value_enum)]
54    pub view: ViewMode,
55
56    /// Run SQL linter and report violations
57    #[arg(long)]
58    pub lint: bool,
59
60    /// Apply deterministic SQL lint auto-fixes in place (requires --lint)
61    #[arg(long, requires = "lint")]
62    pub fix: bool,
63
64    /// Apply fixes only and skip post-fix lint reporting (requires --lint and --fix)
65    #[arg(long, requires_all = ["lint", "fix"])]
66    pub fix_only: bool,
67
68    /// Include unsafe lint auto-fixes (requires --lint and --fix)
69    #[arg(long, requires_all = ["lint", "fix"])]
70    pub unsafe_fixes: bool,
71
72    /// Enable legacy AST-based lint rewrites (opt-in; defaults to off)
73    #[arg(long, requires_all = ["lint", "fix"])]
74    pub legacy_ast_fixes: bool,
75
76    /// Show blocked/display-only fix candidates in lint mode (requires --lint)
77    #[arg(long, requires = "lint")]
78    pub show_fixes: bool,
79
80    /// Comma-separated list of lint rule codes to exclude (e.g., LINT_AM_008,LINT_ST_006)
81    #[arg(long, requires = "lint", value_delimiter = ',')]
82    pub exclude_rules: Vec<String>,
83
84    /// JSON object for per-rule lint options keyed by rule reference
85    /// (e.g., '{"structure.subquery":{"forbid_subquery_in":"both"}}')
86    #[arg(long, requires = "lint", value_name = "JSON")]
87    pub rule_configs: Option<String>,
88
89    /// Number of worker threads to use for lint/fix file processing
90    #[arg(
91        long,
92        requires = "lint",
93        value_name = "N",
94        value_parser = parse_positive_usize
95    )]
96    pub jobs: Option<usize>,
97
98    /// Disable `.gitignore` and standard ignore-file filtering during lint path discovery
99    #[arg(long, requires = "lint")]
100    pub no_respect_gitignore: bool,
101
102    /// Suppress warnings on stderr
103    #[arg(short, long)]
104    pub quiet: bool,
105
106    /// Compact JSON output (no pretty-printing)
107    #[arg(short, long)]
108    pub compact: bool,
109
110    /// Template mode for preprocessing SQL (jinja or dbt)
111    #[cfg(feature = "templating")]
112    #[arg(long, value_enum)]
113    pub template: Option<TemplateArg>,
114
115    /// Template variable in KEY=VALUE format (can be repeated)
116    #[cfg(feature = "templating")]
117    #[arg(long = "template-var", value_name = "KEY=VALUE")]
118    pub template_vars: Vec<String>,
119
120    /// Start HTTP server with embedded web UI
121    #[cfg(feature = "serve")]
122    #[arg(long)]
123    pub serve: bool,
124
125    /// Port for HTTP server (default: 3000)
126    #[cfg(feature = "serve")]
127    #[arg(long, default_value = "3000")]
128    pub port: u16,
129
130    /// Directories to watch for SQL files (can be repeated)
131    #[cfg(feature = "serve")]
132    #[arg(long, value_name = "DIR")]
133    pub watch: Vec<PathBuf>,
134
135    /// Open browser automatically when server starts
136    #[cfg(feature = "serve")]
137    #[arg(long)]
138    pub open: bool,
139}
140
141/// SQL dialect options
142#[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/// Output format options
181#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
182pub enum OutputFormat {
183    /// Human-readable table format
184    Table,
185    /// JSON output
186    Json,
187    /// Mermaid diagram
188    Mermaid,
189    /// HTML report
190    Html,
191    /// DuckDB SQL export
192    Sql,
193    /// CSV archive (zip)
194    Csv,
195    /// XLSX export
196    Xlsx,
197    /// DuckDB database file
198    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/// Template mode for SQL preprocessing
212#[cfg(feature = "templating")]
213#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
214pub enum TemplateArg {
215    /// Plain Jinja2 templating
216    Jinja,
217    /// dbt-style templating with builtin macros
218    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/// Graph detail level for visualization
232#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
233pub enum ViewMode {
234    /// Script/file level relationships
235    Script,
236    /// Table level lineage (default)
237    Table,
238    /// Column level lineage
239    Column,
240    /// Hybrid view (scripts + tables)
241    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}