1use cli_ui::styles::{paint, CYAN, DIM, OK, YELLOW};
11use cli_ui::{header, ok, phase, summary, CliOptions};
12use std::path::PathBuf;
13
14fn 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
38fn available_profiles() -> Vec<String> {
41 vec![
42 "default".into(),
43 "fast".into(),
44 "quality".into(),
45 "debug".into(),
46 ]
47}
48
49fn 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
70fn 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 #[arg(positional, validate(exists, is_file, ext("csv", "json")))]
106 input: PathBuf,
107
108 #[arg(positional, validate(is_dir), action(create_dir_all))]
110 output: PathBuf,
111
112 #[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 #[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 #[arg(
137 section = "Format",
138 long = "json",
139 group = "format",
140 conflicts_with("csv", "toml")
141 )]
142 json: bool,
143
144 #[arg(
146 section = "Format",
147 long = "csv",
148 group = "format",
149 conflicts_with("json", "toml")
150 )]
151 csv: bool,
152
153 #[arg(
155 section = "Format",
156 long = "toml",
157 group = "format",
158 conflicts_with("json", "csv")
159 )]
160 toml: bool,
161
162 #[arg(
165 section = "Network",
166 long = "host",
167 required_unless("local"),
168 conflicts_with("local")
169 )]
170 host: Option<String>,
171
172 #[arg(section = "Network", long = "local", conflicts_with("host"))]
174 local: bool,
175
176 #[arg(
178 section = "Network",
179 short = 'p',
180 long = "port",
181 default = 8080,
182 validate(range(1, 65535))
183 )]
184 port: u32,
185
186 #[arg(section = "Auth", long = "sign", requires("key"))]
189 sign: bool,
190
191 #[arg(
193 section = "Auth",
194 long = "key",
195 validate(exists, is_file, ext("pem", "der"))
196 )]
197 key: Option<PathBuf>,
198
199 #[arg(section = "Auth", long = "no-auth", conflicts_with("sign"))]
201 no_auth: bool,
202
203 #[arg(section = "Processing", short = 'j', long = "jobs",
206 default = env("JOBS", "4"), validate(range(1, 256)))]
207 jobs: usize,
208
209 #[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 #[arg(section = "Processing", long = "slug",
216 validate(custom = validate_slug))]
217 slug: Option<String>,
218
219 #[arg(section = "Processing", long = "pattern", validate(glob("*.*")))]
221 pattern: Option<String>,
222
223 #[arg(
226 section = "Auth",
227 long = "token",
228 validate(env("API_TOKEN", "")),
229 required_unless("no_auth")
230 )]
231 token: Option<String>,
232
233 #[arg(
236 section = "Input / Output",
237 long = "out-file",
238 validate(warn_if(exists))
239 )]
240 out_file: Option<PathBuf>,
241
242 #[arg(section = "Misc", long = "dry-run", negatable)]
245 dry_run: bool,
246
247 #[arg(section = "Misc", short = 'v', long = "verbose", negatable)]
249 verbose: bool,
250
251 #[arg(section = "Misc", long = "profile", complete = available_profiles)]
253 profile: Option<String>,
254
255 #[arg(section = "Misc", long = "example", complete = available_examples)]
257 example: Option<String>,
258
259 #[arg(section = "Misc", long = "bin", complete = available_bins)]
261 bin: Option<String>,
262
263 #[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}