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    /// Dali (sql-parser-service) compatible JSON
202    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/// Template mode for SQL preprocessing
216#[cfg(feature = "templating")]
217#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
218pub enum TemplateArg {
219    /// Plain Jinja2 templating
220    Jinja,
221    /// dbt-style templating with builtin macros
222    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/// Graph detail level for visualization
236#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
237pub enum ViewMode {
238    /// Script/file level relationships
239    Script,
240    /// Table level lineage (default)
241    Table,
242    /// Column level lineage
243    Column,
244    /// Hybrid view (scripts + tables)
245    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}