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    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/// Output format options
183#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
184pub enum OutputFormat {
185    /// Human-readable table format
186    Table,
187    /// JSON output
188    Json,
189    /// Mermaid diagram
190    Mermaid,
191    /// HTML report
192    Html,
193    /// DuckDB SQL export
194    Sql,
195    /// CSV archive (zip)
196    Csv,
197    /// XLSX export
198    Xlsx,
199    /// DuckDB database file
200    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/// Template mode for SQL preprocessing
214#[cfg(feature = "templating")]
215#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
216pub enum TemplateArg {
217    /// Plain Jinja2 templating
218    Jinja,
219    /// dbt-style templating with builtin macros
220    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/// Graph detail level for visualization
234#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
235pub enum ViewMode {
236    /// Script/file level relationships
237    Script,
238    /// Table level lineage (default)
239    Table,
240    /// Column level lineage
241    Column,
242    /// Hybrid view (scripts + tables)
243    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}