Skip to main content

validation/
validation.rs

1//! Example: validation — demonstrates all validate(), action(), and
2//! inter-field constraint attributes.
3//!
4//! Run:
5//!   cargo run --example validation -- --help
6//!   cargo run --example validation -- input.csv output/ --port 80
7//!   cargo run --example validation -- input.csv output/ --json --csv   # conflict error
8//!   cargo run --example validation -- input.csv output/ --sign         # requires --key
9
10use cli_ui::styles::{paint, CYAN, DIM, OK, YELLOW};
11use cli_ui::{header, ok, phase, summary, CliOptions};
12use std::path::PathBuf;
13
14// ── Validator functions ───────────────────────────────────────────────────────
15
16fn validate_worker_name(s: &str) -> Result<(), String> {
17    if s.chars().all(|c| c.is_alphanumeric() || c == '-') {
18        Ok(())
19    } else {
20        Err(format!(
21            "`{s}` contains invalid characters — only a-z, 0-9, and `-` allowed"
22        ))
23    }
24}
25
26fn validate_slug(s: &str) -> Result<(), String> {
27    if s.chars()
28        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
29    {
30        Ok(())
31    } else {
32        Err(format!(
33            "`{s}` is not a valid slug — only a-z, 0-9, and `-` allowed"
34        ))
35    }
36}
37
38// ── Completion providers ──────────────────────────────────────────────────────
39
40fn available_profiles() -> Vec<String> {
41    vec![
42        "default".into(),
43        "fast".into(),
44        "quality".into(),
45        "debug".into(),
46    ]
47}
48
49/// Scans examples/ and returns filenames without .rs extension.
50/// Returns empty Vec if the directory doesn't exist — identical to cargo behaviour.
51fn available_examples() -> Vec<String> {
52    let Ok(rd) = std::fs::read_dir("examples") else {
53        return Vec::new();
54    };
55    let mut names: Vec<String> = rd
56        .flatten()
57        .filter_map(|e| {
58            let path = e.path();
59            if path.is_file() && path.extension()?.to_str() == Some("rs") {
60                Some(path.file_stem()?.to_string_lossy().to_string())
61            } else {
62                None
63            }
64        })
65        .collect();
66    names.sort();
67    names
68}
69
70/// Scans src/bin/ and returns filenames without .rs extension.
71fn available_bins() -> Vec<String> {
72    let Ok(rd) = std::fs::read_dir("src/bin") else {
73        return Vec::new();
74    };
75    let mut names: Vec<String> = rd
76        .flatten()
77        .filter_map(|e| {
78            let path = e.path();
79            if path.is_file() && path.extension()?.to_str() == Some("rs") {
80                Some(path.file_stem()?.to_string_lossy().to_string())
81            } else {
82                None
83            }
84        })
85        .collect();
86    names.sort();
87    names
88}
89
90#[allow(dead_code)]
91#[derive(CliOptions)]
92#[cli(
93    name = "processor",
94    about = "process and transform data files",
95    tagline = "fast, validated, reliable",
96    theme = "blue",
97    example = "processor input.csv output/ --format json --jobs 8",
98    example = "processor input.csv output/ --sign --key signing.pem",
99    hint = "run with --dry-run to preview without writing",
100    url = "https://github.com/you/processor"
101)]
102struct Opt {
103    // ── positionals ───────────────────────────────────────────────────
104    /// Input CSV or JSON file (must exist)
105    #[arg(positional, validate(exists, is_file, ext("csv", "json")))]
106    input: PathBuf,
107
108    /// Output directory (created automatically if missing)
109    #[arg(positional, validate(is_dir), action(create_dir_all))]
110    output: PathBuf,
111
112    // ── input / output alternatives ───────────────────────────────────
113    /// Input file (alternative to positional) — only .csv and .json
114    #[arg(
115        section = "Input / Output",
116        short = 'i',
117        long = "input",
118        validate(exists, is_file, ext("csv", "json")),
119        conflicts_with("input")
120    )]
121    alt_input: Option<PathBuf>,
122
123    /// Output directory (alternative to positional)
124    #[arg(
125        section = "Input / Output",
126        short = 'o',
127        long = "output",
128        validate(is_dir),
129        action(create_dir_all),
130        conflicts_with("output")
131    )]
132    alt_output: Option<PathBuf>,
133
134    // ── output format — mutually exclusive group ───────────────────────
135    /// Output as JSON
136    #[arg(
137        section = "Format",
138        long = "json",
139        group = "format",
140        conflicts_with("csv", "toml")
141    )]
142    json: bool,
143
144    /// Output as CSV
145    #[arg(
146        section = "Format",
147        long = "csv",
148        group = "format",
149        conflicts_with("json", "toml")
150    )]
151    csv: bool,
152
153    /// Output as TOML
154    #[arg(
155        section = "Format",
156        long = "toml",
157        group = "format",
158        conflicts_with("json", "csv")
159    )]
160    toml: bool,
161
162    // ── network ───────────────────────────────────────────────────────
163    /// Remote host — required unless --local is set
164    #[arg(
165        section = "Network",
166        long = "host",
167        required_unless("local"),
168        conflicts_with("local")
169    )]
170    host: Option<String>,
171
172    /// Run in local mode (no host required)
173    #[arg(section = "Network", long = "local", conflicts_with("host"))]
174    local: bool,
175
176    /// Port number (1–65535)
177    #[arg(
178        section = "Network",
179        short = 'p',
180        long = "port",
181        default = 8080,
182        validate(range(1, 65535))
183    )]
184    port: u32,
185
186    // ── auth — requires / required_unless ─────────────────────────────
187    /// Sign the output — requires --key
188    #[arg(section = "Auth", long = "sign", requires("key"))]
189    sign: bool,
190
191    /// Signing key file — .pem or .der
192    #[arg(
193        section = "Auth",
194        long = "key",
195        validate(exists, is_file, ext("pem", "der"))
196    )]
197    key: Option<PathBuf>,
198
199    /// Disable authentication — conflicts with --sign
200    #[arg(section = "Auth", long = "no-auth", conflicts_with("sign"))]
201    no_auth: bool,
202
203    // ── processing ────────────────────────────────────────────────────
204    /// Parallel workers (from $JOBS env or 4)
205    #[arg(section = "Processing", short = 'j', long = "jobs",
206          default = env("JOBS", "4"), validate(range(1, 256)))]
207    jobs: usize,
208
209    /// Worker name — 3–32 chars, alphanumeric + hyphen
210    #[arg(section = "Processing", long = "worker-name",
211          validate(min_len(3), max_len(32), custom = validate_worker_name))]
212    worker_name: Option<String>,
213
214    /// Slug — only a-z, 0-9, hyphen allowed
215    #[arg(section = "Processing", long = "slug",
216          validate(custom = validate_slug))]
217    slug: Option<String>,
218
219    /// File pattern filter (glob)
220    #[arg(section = "Processing", long = "pattern", validate(glob("*.*")))]
221    pattern: Option<String>,
222
223    // ── env validator ─────────────────────────────────────────────────
224    /// API token — validated against $API_TOKEN env var
225    #[arg(
226        section = "Auth",
227        long = "token",
228        validate(env("API_TOKEN", "")),
229        required_unless("no_auth")
230    )]
231    token: Option<String>,
232
233    // ── warn_if ───────────────────────────────────────────────────────
234    /// Output file that will warn if it already exists
235    #[arg(
236        section = "Input / Output",
237        long = "out-file",
238        validate(warn_if(exists))
239    )]
240    out_file: Option<PathBuf>,
241
242    // ── misc ──────────────────────────────────────────────────────────
243    /// Preview without writing anything
244    #[arg(section = "Misc", long = "dry-run", negatable)]
245    dry_run: bool,
246
247    /// Verbose output
248    #[arg(section = "Misc", short = 'v', long = "verbose", negatable)]
249    verbose: bool,
250
251    /// Profile — dynamic completions via available_profiles()
252    #[arg(section = "Misc", long = "profile", complete = available_profiles)]
253    profile: Option<String>,
254
255    /// Example name — completes from examples/ without .rs (like cargo run --example)
256    #[arg(section = "Misc", long = "example", complete = available_examples)]
257    example: Option<String>,
258
259    /// Binary name — completes from src/bin/ without .rs (like cargo run --bin)
260    #[arg(section = "Misc", long = "bin", complete = available_bins)]
261    bin: Option<String>,
262
263    /// Skip — not parsed, always default
264    #[arg(skip)]
265    _runtime_state: Vec<String>,
266}
267
268fn main() {
269    let opt = Opt::parse();
270    let input = opt.resolved_alt_input();
271    let output = opt.resolved_alt_output();
272    let start = std::time::Instant::now();
273
274    header!(
275        "processor",
276        env!("CARGO_PKG_VERSION"),
277        "process and transform data files",
278        "fast, validated, reliable"
279    );
280
281    let fmt = if opt.json {
282        "json"
283    } else if opt.csv {
284        "csv"
285    } else if opt.toml {
286        "toml"
287    } else {
288        "default"
289    };
290
291    phase!("init", "reading {}", input.display());
292    phase!("cfg", "format={} jobs={} port={}", fmt, opt.jobs, opt.port);
293
294    if opt.dry_run {
295        phase!("mode", "dry run — nothing will be written");
296    }
297    if opt.verbose {
298        phase!("debug", "verbose output enabled");
299    }
300    if opt.sign {
301        phase!("auth", "signing enabled, key={:?}", opt.key);
302    }
303    if opt.no_auth {
304        phase!("auth", "authentication disabled");
305    }
306    if let Some(ref p) = opt.pattern {
307        phase!("filter", "pattern: {p}");
308    }
309    if let Some(ref n) = opt.worker_name {
310        phase!("worker", "name: {n}");
311    }
312    if let Some(ref p) = opt.profile {
313        phase!("profile", "{p}");
314    }
315    if let Some(ref e) = opt.example {
316        phase!("example", "{e}");
317    }
318    if let Some(ref b) = opt.bin {
319        phase!("bin", "{b}");
320    }
321
322    if !opt.dry_run {
323        let out = output.join(format!("result.{fmt}"));
324        ok!(out.display());
325    }
326
327    let elapsed = start.elapsed().as_millis();
328    summary! {
329        done:    "Processing complete",
330        "input"  => paint(CYAN, &input.display().to_string()),
331        "output" => paint(CYAN, &output.display().to_string()),
332        section,
333        "format" => paint(OK,     fmt),
334        "jobs"   => paint(OK,     &opt.jobs.to_string()),
335        "port"   => paint(YELLOW, &opt.port.to_string()),
336        "time"   => paint(DIM,    &format!("{elapsed}ms")),
337    }
338}