1use std::env;
2use std::fs;
3use std::io::{self, Read, Write};
4
5mod catalog;
6mod errors;
7mod parser;
8mod spec;
9mod web_assets;
10mod web_payload;
11mod web_server;
12
13use treease_core::core::{
14 Context, CoreError, Encoder, NodeId, Printer, Reader, SystemError, VecPrinterWriter,
15};
16use treease_core::evaluator::StreamEvaluator;
17use treease_core::formats::{
18 CsvEncoder, Encode, FormatPreferences, JavascriptEncoder, JsonEncoder, PythonEncoder,
19 TomlEncoder, YamlEncoder,
20};
21
22#[derive(Debug, Clone)]
23struct ParsedArgs {
24 command: CommandKind,
25 expression: String,
26 files: Vec<String>,
27 help: bool,
28 null_input: bool,
29 exit_status: bool,
30 input_format: Option<String>,
31 output_format: Option<String>,
32 pretty_print: Option<bool>,
33 indent: Option<i32>,
34 unwrap_scalar: bool,
35 no_doc: bool,
36 inplace: bool,
37 metadata_format: Option<String>,
38 metadata_target: Option<String>,
39 metadata_category: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43enum CommandKind {
44 Run,
45 Web,
46 Help,
47 OperatorsList,
48 OperatorsGet,
49 OperatorsSearch,
50 FormatsList,
51 FormatsGet,
52 ExamplesList,
53 ExamplesGet,
54 ExamplesSearch,
55 Doctor,
56}
57
58impl Default for ParsedArgs {
59 fn default() -> Self {
60 Self {
61 command: CommandKind::Run,
62 expression: ".".to_string(),
63 files: Vec::new(),
64 help: false,
65 null_input: false,
66 exit_status: false,
67 input_format: None,
68 output_format: None,
69 pretty_print: None,
70 indent: None,
71 unwrap_scalar: false,
72 no_doc: false,
73 inplace: false,
74 metadata_format: None,
75 metadata_target: None,
76 metadata_category: None,
77 }
78 }
79}
80
81#[derive(Debug)]
82enum CliError {
83 #[allow(dead_code)]
84 MissingValue(&'static str),
85 InvalidIndent(String),
86 UnsupportedFormat(String),
87 UnsupportedOperator(String),
88 UnknownExample(String),
89 InvalidFlagCombination,
90 MultipleInputFilesForInplace,
91 UnsupportedWebFlag(&'static str),
92 InvalidWebInputCount,
93 WebAssetDownload(String),
94 WebAssetManifest(String),
95 WebAssetCache(String),
96 #[allow(dead_code)]
97 WebServer(String),
98 #[allow(dead_code)]
99 WebForbidden,
100 UnknownCommand(String),
101 UnknownFlag(String),
102 Eval(String),
103 Io(io::Error),
104 Core(CoreError),
105}
106
107impl From<io::Error> for CliError {
108 fn from(value: io::Error) -> Self {
109 Self::Io(value)
110 }
111}
112
113impl From<CoreError> for CliError {
114 fn from(value: CoreError) -> Self {
115 Self::Core(value)
116 }
117}
118
119#[derive(Debug, Clone)]
120struct InputPayload {
121 name: String,
122 bytes: Vec<u8>,
123}
124
125impl InputPayload {
126 fn display_name(&self) -> &str {
127 &self.name
128 }
129}
130
131struct CliPrinterEncoder {
132 inner: Box<dyn Encode>,
133}
134
135impl Encoder for CliPrinterEncoder {
136 fn encode(
137 &self,
138 ctx: &mut Context,
139 node: NodeId,
140 writer: &mut dyn std::io::Write,
141 ) -> Result<(), CoreError> {
142 let store = ctx
143 .current_print_store()
144 .ok_or(CoreError::System(SystemError::Error))?;
145 self.inner.encode(store, node, writer)
146 }
147}
148
149pub fn main() {
150 let args: Vec<String> = env::args().collect();
151 let exit_code = match run(&args) {
152 Ok(code) => code,
153 Err(err) => {
154 eprint!("{}", render_runtime_error(&err));
155 1
156 }
157 };
158 std::process::exit(exit_code);
159}
160
161fn run(raw_args: &[String]) -> Result<i32, CliError> {
162 let parsed = parse_args(raw_args)?;
163
164 match parsed.command {
165 CommandKind::Help => {
166 print!("{}", render_help(&parsed));
167 return Ok(0);
168 }
169 CommandKind::Web => return run_web_command(&parsed),
170 CommandKind::Run => {}
171 _ => {
172 let output = execute_metadata_command(&parsed)?;
173 io::Write::write_all(&mut io::stdout(), &output)?;
174 return Ok(0);
175 }
176 }
177
178 let inputs = if parsed.null_input {
179 Vec::new()
180 } else {
181 read_inputs(&parsed.files)?
182 };
183
184 let output = execute_command(&parsed, &inputs)?;
185
186 if parsed.inplace {
187 fs::write(&parsed.files[0], output)?;
188 return Ok(0);
189 }
190
191 io::Write::write_all(&mut io::stdout(), &output)?;
192 Ok(compute_exit_status(parsed.exit_status, &output) as i32)
193}
194
195fn run_web_command(parsed: &ParsedArgs) -> Result<i32, CliError> {
196 let inputs = read_inputs(&parsed.files)?;
197 let payload = web_payload::build_cli_graph_result_payload(parsed, &inputs)?;
198 let result_json =
199 serde_json::to_vec(&payload).map_err(|error| CliError::Eval(error.to_string()))?;
200 let assets_dir = web_assets::ensure_available()?;
201 let server = web_server::WebServer::bind(result_json, assets_dir)?;
202
203 let mut stdout = io::stdout();
204 writeln!(stdout, "{}", server.graph_url())?;
205 stdout.flush()?;
206
207 server.serve_forever()?;
208 Ok(0)
209}
210
211fn execute_command(parsed: &ParsedArgs, inputs: &[InputPayload]) -> Result<Vec<u8>, CliError> {
212 let mut registry = treease_core::init()?;
213 let result = (|| {
214 let mut ctx = Context::empty(registry.handle());
215 let configured = configured_formats(parsed, inputs.first())?;
216 let prefs = configured_preferences(parsed, &configured.output)?;
217 let encoder = CliPrinterEncoder {
218 inner: make_value_encoder(&configured.output, prefs.clone())?,
219 };
220 let writer = VecPrinterWriter::new();
221 let mut printer = Printer::new(encoder, writer);
222
223 let mut evaluator = StreamEvaluator::new();
224 if parsed.null_input {
225 evaluator
226 .evaluate_new(&mut ctx, &parsed.expression, &mut printer)
227 .map_err(|err| CliError::Eval(format!("{err:?}")))?;
228 } else {
229 let parsed_expression = parse_expression(&parsed.expression)?;
230 let mut total_processed_docs = 0_u32;
231 for payload in inputs {
232 let input_format = resolve_input_format(parsed, payload)?;
233 let mut cursor = io::Cursor::new(payload.bytes.as_slice());
234 let mut reader = Reader::new(&mut cursor);
235 total_processed_docs += evaluator
236 .evaluate_with_format(
237 &mut ctx,
238 payload.display_name(),
239 &mut reader,
240 parsed_expression.as_deref(),
241 &mut printer,
242 Some(input_format.as_str()),
243 )
244 .map_err(|err| CliError::Eval(format!("{err:?}")))?;
245 }
246 if total_processed_docs == 0 {
247 evaluator
248 .evaluate_new(&mut ctx, &parsed.expression, &mut printer)
249 .map_err(|err| CliError::Eval(format!("{err:?}")))?;
250 }
251 }
252
253 Ok(printer.into_writer().into_bytes())
254 })();
255 treease_core::deinit(&mut registry);
256 result
257}
258
259fn parse_expression(
260 expression: &str,
261) -> Result<Option<Box<treease_core::core::ExpressionNode>>, CliError> {
262 treease_core::parser::parse_expression(expression)
263 .map_err(|error| CliError::Eval(format!("{error:?}")))
264}
265
266fn read_inputs(files: &[String]) -> Result<Vec<InputPayload>, CliError> {
267 let sources = if files.is_empty() {
268 vec!["-".to_string()]
269 } else {
270 files.to_vec()
271 };
272 let mut inputs = Vec::with_capacity(sources.len());
273 for source in sources {
274 if source == "-" {
275 let mut bytes = Vec::new();
276 io::stdin().read_to_end(&mut bytes)?;
277 inputs.push(InputPayload {
278 name: "<stdin>".to_string(),
279 bytes,
280 });
281 } else {
282 inputs.push(InputPayload {
283 name: source.clone(),
284 bytes: fs::read(&source)?,
285 });
286 }
287 }
288 Ok(inputs)
289}
290
291fn configured_formats(
292 parsed: &ParsedArgs,
293 first_input: Option<&InputPayload>,
294) -> Result<ConfiguredFormats, CliError> {
295 let input = first_input
296 .map(|payload| resolve_input_format(parsed, payload))
297 .transpose()?
298 .unwrap_or_else(|| "json".to_string());
299 let output = match parsed.output_format.as_deref() {
300 Some(value) => canonical_cli_format(value)?,
301 None if first_input.is_some() => input.clone(),
302 None => "yaml".to_string(),
303 };
304 Ok(ConfiguredFormats { output })
305}
306
307fn resolve_input_format(parsed: &ParsedArgs, payload: &InputPayload) -> Result<String, CliError> {
308 if let Some(value) = parsed.input_format.as_deref() {
309 return canonical_cli_format(value);
310 }
311
312 Ok(guess_input_format(payload).unwrap_or_else(|| "json".to_string()))
313}
314
315fn guess_input_format(payload: &InputPayload) -> Option<String> {
316 guess_input_format_from_filename(payload.display_name())
317 .or_else(|| guess_input_format_from_content(&payload.bytes))
318}
319
320fn guess_input_format_from_filename(filename: &str) -> Option<String> {
321 if matches!(filename, "<stdin>" | "-") {
322 return None;
323 }
324
325 let last_segment = filename.rsplit(['/', '\\']).next().unwrap_or(filename);
326 let dot_pos = last_segment.rfind('.')?;
327 let ext = &last_segment[dot_pos + 1..];
328 if ext.is_empty() {
329 return None;
330 }
331
332 treease_core::core::find_cli_format_spec(ext).map(|spec| spec.name.to_string())
333}
334
335fn guess_input_format_from_content(bytes: &[u8]) -> Option<String> {
336 let text = std::str::from_utf8(bytes).ok()?;
337 let trimmed = text
338 .trim_start_matches('\u{feff}')
339 .trim_matches(|ch: char| ch.is_ascii_whitespace());
340 if trimmed.is_empty() {
341 return None;
342 }
343
344 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
345 return Some("json".to_string());
346 }
347 if looks_like_toml(trimmed) {
348 return Some("toml".to_string());
349 }
350 if looks_like_yaml(trimmed) {
351 return Some("yaml".to_string());
352 }
353 if looks_like_csv(trimmed) {
354 return Some("csv".to_string());
355 }
356
357 None
358}
359
360fn looks_like_toml(text: &str) -> bool {
361 text.lines()
362 .map(str::trim)
363 .filter(|line| !line.is_empty() && !line.starts_with('#'))
364 .any(|line| {
365 if line.starts_with('[') && line.ends_with(']') && line.len() > 2 {
366 return true;
367 }
368 let Some((key, value)) = line.split_once('=') else {
369 return false;
370 };
371 !key.trim().is_empty()
372 && !value.trim().is_empty()
373 && key.trim().chars().all(|ch| {
374 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '"' | '\'' | ' ')
375 })
376 })
377}
378
379fn looks_like_yaml(text: &str) -> bool {
380 if text.starts_with("---") || text.starts_with("- ") {
381 return true;
382 }
383
384 text.lines()
385 .map(str::trim)
386 .filter(|line| !line.is_empty() && !line.starts_with('#'))
387 .any(|line| {
388 if line.starts_with("- ") {
389 return true;
390 }
391 let Some((key, value)) = line.split_once(':') else {
392 return false;
393 };
394 !key.trim().is_empty()
395 && !key.contains('{')
396 && !key.contains('[')
397 && (!value.trim().is_empty() || line.ends_with(':'))
398 })
399}
400
401fn looks_like_csv(text: &str) -> bool {
402 let rows: Vec<&str> = text
403 .lines()
404 .map(str::trim)
405 .filter(|line| !line.is_empty())
406 .take(3)
407 .collect();
408 if rows.len() < 2 {
409 return false;
410 }
411
412 let first_count = rows[0].split(',').count();
413 first_count > 1
414 && rows[1..]
415 .iter()
416 .all(|row| row.split(',').count() == first_count)
417}
418
419fn configured_preferences(
420 parsed: &ParsedArgs,
421 output_format: &str,
422) -> Result<FormatPreferences, CliError> {
423 let spec = treease_core::core::find_cli_format_spec(output_format)
424 .ok_or_else(|| CliError::UnsupportedFormat(output_format.to_string()))?;
425 let language = spec
426 .format_language
427 .ok_or_else(|| CliError::UnsupportedFormat(output_format.to_string()))?;
428 let mut prefs = treease_core::formats::configured_language_preferences().effective(language);
429 let pretty_print = parsed.pretty_print.unwrap_or(spec.default_pretty_print);
430 prefs.indent = if pretty_print {
431 parsed.indent.unwrap_or(prefs.indent)
432 } else {
433 0
434 };
435 prefs.unwrap_scalar = parsed.unwrap_scalar;
436 if parsed.no_doc {
437 prefs.print_doc_separators = false;
438 }
439 Ok(prefs)
440}
441
442fn canonical_cli_format(value: &str) -> Result<String, CliError> {
443 treease_core::core::find_cli_format_spec(value)
444 .map(|spec| spec.name.to_string())
445 .ok_or_else(|| CliError::UnsupportedFormat(value.to_string()))
446}
447
448fn make_value_encoder(
449 format_name: &str,
450 prefs: FormatPreferences,
451) -> Result<Box<dyn Encode>, CliError> {
452 match format_name {
453 "json" => Ok(Box::new(JsonEncoder::new(prefs))),
454 "yaml" => Ok(Box::new(YamlEncoder::new(prefs))),
455 "toml" => Ok(Box::new(TomlEncoder::new(prefs))),
456 "csv" => Ok(Box::new(CsvEncoder::new(prefs))),
457 "python" => Ok(Box::new(PythonEncoder::new(prefs))),
458 "javascript" => Ok(Box::new(JavascriptEncoder::new(prefs))),
459 _ => Err(CliError::UnsupportedFormat(format_name.to_string())),
460 }
461}
462
463fn parse_args(argv: &[String]) -> Result<ParsedArgs, CliError> {
464 let parsed = parser::parse_args_with_clap(argv)?;
465 validate_args(&parsed)?;
466 Ok(parsed)
467}
468
469fn validate_args(parsed: &ParsedArgs) -> Result<(), CliError> {
470 if parsed.command == CommandKind::Web {
471 if parsed.null_input {
472 return Err(CliError::UnsupportedWebFlag("--null-input"));
473 }
474 if parsed.exit_status {
475 return Err(CliError::UnsupportedWebFlag("--exit-status"));
476 }
477 if parsed.inplace {
478 return Err(CliError::UnsupportedWebFlag("--inplace"));
479 }
480 if parsed.files.len() != 1 {
481 return Err(CliError::InvalidWebInputCount);
482 }
483 }
484
485 if parsed.inplace {
486 if parsed.null_input {
487 return Err(CliError::InvalidFlagCombination);
488 }
489 if parsed.files.len() != 1 {
490 return Err(CliError::MultipleInputFilesForInplace);
491 }
492 if parsed.files[0] == "-" {
493 return Err(CliError::InvalidFlagCombination);
494 }
495 }
496 Ok(())
497}
498
499fn render_help(parsed: &ParsedArgs) -> String {
500 let format = parsed.metadata_format.as_deref().unwrap_or("text");
501 let target = resolve_help_target(parsed.metadata_target.as_deref());
502
503 if format == "json" {
504 return if let Some(command) = target {
505 serde_json::to_string_pretty(&command).expect("CLI command spec JSON should serialize")
506 + "\n"
507 } else {
508 serde_json::to_string_pretty(&spec::root_help_json_value())
509 .expect("root CLI help JSON should serialize")
510 + "\n"
511 };
512 }
513
514 match target {
515 Some(command) => spec::render_command_text_help(&command),
516 None => spec::render_root_text_help(),
517 }
518}
519
520fn resolve_help_target(target: Option<&str>) -> Option<spec::CliCommandSpec> {
521 let target = target?;
522 let segments = target.split_whitespace().collect::<Vec<_>>();
523 if segments.is_empty() {
524 return None;
525 }
526
527 let mut path = vec!["treease"];
528 path.extend(segments);
529 spec::find_command_spec(&path)
530}
531
532fn execute_metadata_command(parsed: &ParsedArgs) -> Result<Vec<u8>, CliError> {
533 let json = matches!(parsed.metadata_format.as_deref(), Some("json"));
534 let output = match parsed.command {
535 CommandKind::Help => render_help(parsed),
536 CommandKind::OperatorsList => {
537 let operators = catalog::operators()
538 .into_iter()
539 .filter(|operator| {
540 parsed
541 .metadata_category
542 .as_deref()
543 .is_none_or(|category| operator.category == category)
544 })
545 .collect::<Vec<_>>();
546 render_metadata_value(&operators, json)?
547 }
548 CommandKind::OperatorsGet => {
549 let name = parsed
550 .metadata_target
551 .as_deref()
552 .ok_or(CliError::MissingValue("operator name"))?;
553 let operator = catalog::find_operator(name)
554 .ok_or_else(|| CliError::UnsupportedOperator(name.to_string()))?;
555 render_metadata_value(&operator, json)?
556 }
557 CommandKind::OperatorsSearch => {
558 let query = parsed
559 .metadata_target
560 .as_deref()
561 .ok_or(CliError::MissingValue("operator search query"))?;
562 render_metadata_value(&catalog::search_operators(query), json)?
563 }
564 CommandKind::FormatsList => render_metadata_value(&catalog::formats(), json)?,
565 CommandKind::FormatsGet => {
566 let name = parsed
567 .metadata_target
568 .as_deref()
569 .ok_or(CliError::MissingValue("format name"))?;
570 let format = catalog::find_format(name)
571 .ok_or_else(|| CliError::UnsupportedFormat(name.to_string()))?;
572 render_metadata_value(&format, json)?
573 }
574 CommandKind::ExamplesList => render_metadata_value(&catalog::examples(), json)?,
575 CommandKind::ExamplesGet => {
576 let name = parsed
577 .metadata_target
578 .as_deref()
579 .ok_or(CliError::MissingValue("example name"))?;
580 let example = catalog::find_example(name)
581 .ok_or_else(|| CliError::UnknownExample(name.to_string()))?;
582 render_metadata_value(&example, json)?
583 }
584 CommandKind::ExamplesSearch => {
585 let query = parsed
586 .metadata_target
587 .as_deref()
588 .ok_or(CliError::MissingValue("example search query"))?;
589 render_metadata_value(&catalog::search_examples(query), json)?
590 }
591 CommandKind::Doctor => render_metadata_value(&catalog::doctor_info(), json)?,
592 CommandKind::Web => return Err(CliError::InvalidFlagCombination),
593 CommandKind::Run => return Err(CliError::InvalidFlagCombination),
594 };
595
596 Ok(output.into_bytes())
597}
598
599fn render_metadata_value<T: serde::Serialize>(value: &T, _json: bool) -> Result<String, CliError> {
600 serde_json::to_string_pretty(value)
601 .map(|mut output| {
602 output.push('\n');
603 output
604 })
605 .map_err(|error| CliError::Eval(error.to_string()))
606}
607
608fn render_runtime_error(err: &CliError) -> String {
609 errors::render_text(err)
610}
611
612fn compute_exit_status(enabled: bool, output: &[u8]) -> u8 {
613 let trimmed = std::str::from_utf8(output)
614 .unwrap_or("")
615 .trim_matches(|ch| matches!(ch, ' ' | '\t' | '\r' | '\n' | '\0'));
616 let printed = !trimmed.is_empty() && trimmed != "null" && trimmed != "false";
617 if enabled && !printed { 1 } else { 0 }
618}
619
620#[derive(Debug, Clone)]
621struct ConfiguredFormats {
622 output: String,
623}
624
625pub mod internal_metadata {
626 pub fn cli_help_json() -> serde_json::Value {
627 super::spec::root_help_json_value()
628 }
629
630 pub fn operators_json() -> serde_json::Value {
631 serde_json::to_value(super::catalog::operators()).expect("operators should serialize")
632 }
633
634 pub fn formats_json() -> serde_json::Value {
635 serde_json::to_value(super::catalog::formats()).expect("formats should serialize")
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use std::fs;
643 use std::io::{Read, Write};
644 use std::net::TcpStream;
645 use std::path::PathBuf;
646 use std::time::{SystemTime, UNIX_EPOCH};
647
648 fn request_web_server_once(state: web_server::WebServerState, target: &str) -> Vec<u8> {
649 let server = web_server::WebServer::bind_for_test(state).expect("server should bind");
650 let addr = server.local_addr();
651 let handle = std::thread::spawn(move || {
652 server
653 .serve_once_for_test()
654 .expect("server should handle one request");
655 });
656
657 let mut stream = TcpStream::connect(addr).expect("client should connect");
658 write!(
659 stream,
660 "GET {target} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"
661 )
662 .expect("request should write");
663 stream.flush().expect("request should flush");
664
665 let mut response = Vec::new();
666 stream
667 .read_to_end(&mut response)
668 .expect("response should read");
669 handle.join().expect("server thread should join");
670 response
671 }
672
673 fn test_web_server_state() -> web_server::WebServerState {
674 let assets_dir = test_asset_dir(&[
675 ("index.html", b"<html><body>graph</body></html>".as_slice()),
676 ("_app/app.js", b"console.log('graph')".as_slice()),
677 ("core.26062120.wasm", b"\0asmtest".as_slice()),
678 ]);
679 web_server::WebServerState {
680 token: "test-token".to_string(),
681 result_json: br#"{"ok":true}"#.to_vec(),
682 assets_dir,
683 }
684 }
685
686 fn assert_response_contains(response: &[u8], expected: &str) {
687 let text = std::str::from_utf8(response).expect("response should be utf8");
688 assert!(
689 text.contains(expected),
690 "response did not contain {expected:?}:\n{text}"
691 );
692 }
693
694 fn response_body(response: &[u8]) -> &[u8] {
695 response
696 .windows(4)
697 .position(|window| window == b"\r\n\r\n")
698 .map(|index| &response[index + 4..])
699 .expect("response should include header terminator")
700 }
701
702 #[test]
703 fn embedded_assets_lookup_normalizes_graph_route_to_index() {
704 let asset_dir = test_asset_dir(&[
705 ("index.html", b"<html></html>".as_slice()),
706 ("_app/app.js", b"console.log(1)".as_slice()),
707 ]);
708
709 let index = web_assets::find_asset(&asset_dir, "/cli/graph")
710 .expect("graph route should use index.html");
711 assert!(index.path.ends_with("index.html"));
712
713 let graph_with_query = web_assets::find_asset(&asset_dir, "/cli/graph?token=x")
714 .expect("graph route with query should use index.html");
715 assert!(graph_with_query.path.ends_with("index.html"));
716
717 let script = web_assets::find_asset(&asset_dir, "/_app/app.js?hash=1")
718 .expect("static asset should resolve directly");
719 assert_eq!(script.content_type, "text/javascript; charset=utf-8");
720
721 assert!(
722 web_assets::find_asset(&asset_dir, "/missing").is_none(),
723 "unknown extensionless route should not fallback"
724 );
725 assert!(
726 web_assets::find_asset(&asset_dir, "/api/status").is_none(),
727 "api-looking route should not fallback"
728 );
729 assert!(
730 web_assets::find_asset(&asset_dir, "/missing.txt").is_none(),
731 "missing static asset should not fallback"
732 );
733 assert!(
734 web_assets::find_asset(&asset_dir, "/cli/graph/anything").is_none(),
735 "graph fallback should not cover unknown subpaths"
736 );
737 }
738
739 #[test]
740 fn web_server_serves_result_only_with_matching_token() {
741 let missing = request_web_server_once(test_web_server_state(), "/cli/result");
742 assert_response_contains(&missing, "HTTP/1.1 403 Forbidden");
743
744 let wrong = request_web_server_once(test_web_server_state(), "/cli/result?token=wrong");
745 assert_response_contains(&wrong, "HTTP/1.1 403 Forbidden");
746
747 let matching =
748 request_web_server_once(test_web_server_state(), "/cli/result?token=test-token");
749 assert_response_contains(&matching, "HTTP/1.1 200 OK");
750 assert_response_contains(&matching, "Content-Type: application/json; charset=utf-8");
751 assert_eq!(response_body(&matching), br#"{"ok":true}"#);
752 }
753
754 fn test_asset_dir(files: &[(&str, &[u8])]) -> PathBuf {
755 let unique = SystemTime::now()
756 .duration_since(UNIX_EPOCH)
757 .expect("time should be monotonic enough for tests")
758 .as_nanos();
759 let root = std::env::temp_dir().join(format!("treease-cli-test-assets-{unique}"));
760 fs::create_dir_all(&root).expect("test asset root should be creatable");
761 for (relative, bytes) in files {
762 let path = root.join(relative);
763 if let Some(parent) = path.parent() {
764 fs::create_dir_all(parent).expect("test asset parent should be creatable");
765 }
766 fs::write(path, bytes).expect("test asset file should write");
767 }
768 root
769 }
770
771 #[test]
772 fn web_server_serves_graph_route_from_index_asset() {
773 let missing = request_web_server_once(test_web_server_state(), "/cli/graph");
774 assert_response_contains(&missing, "HTTP/1.1 403 Forbidden");
775
776 let wrong = request_web_server_once(test_web_server_state(), "/cli/graph?token=wrong");
777 assert_response_contains(&wrong, "HTTP/1.1 403 Forbidden");
778
779 let graph = request_web_server_once(test_web_server_state(), "/cli/graph?token=test-token");
780 assert_response_contains(&graph, "HTTP/1.1 200 OK");
781 assert_response_contains(&graph, "Content-Type: text/html; charset=utf-8");
782 assert_eq!(response_body(&graph), b"<html><body>graph</body></html>");
783
784 let static_asset = request_web_server_once(test_web_server_state(), "/_app/app.js");
785 assert_response_contains(&static_asset, "HTTP/1.1 200 OK");
786 assert_response_contains(
787 &static_asset,
788 "Content-Type: text/javascript; charset=utf-8",
789 );
790 assert_eq!(response_body(&static_asset), b"console.log('graph')");
791
792 let root = request_web_server_once(test_web_server_state(), "/");
793 assert_response_contains(&root, "HTTP/1.1 200 OK");
794 assert_eq!(response_body(&root), b"<html><body>graph</body></html>");
795
796 let direct_index = request_web_server_once(test_web_server_state(), "/index.html");
797 assert_response_contains(&direct_index, "HTTP/1.1 200 OK");
798 assert_eq!(
799 response_body(&direct_index),
800 b"<html><body>graph</body></html>"
801 );
802
803 let wasm_asset = request_web_server_once(test_web_server_state(), "/core.26062120.wasm");
804 assert_response_contains(&wasm_asset, "HTTP/1.1 200 OK");
805 assert_response_contains(&wasm_asset, "Content-Type: application/wasm");
806 assert_eq!(response_body(&wasm_asset), b"\0asmtest");
807
808 let graph_subpath = request_web_server_once(
809 test_web_server_state(),
810 "/cli/graph/anything?token=test-token",
811 );
812 assert_response_contains(&graph_subpath, "HTTP/1.1 404 Not Found");
813
814 let unknown = request_web_server_once(test_web_server_state(), "/missing");
815 assert_response_contains(&unknown, "HTTP/1.1 404 Not Found");
816 }
817
818 #[test]
819 fn default_invocation_parses_expression_and_files() {
820 let raw = vec![
821 "treease".to_string(),
822 ".foo".to_string(),
823 "a.json".to_string(),
824 ];
825 let parsed = parse_args(&raw).expect("parse should succeed");
826 assert_eq!(parsed.command, CommandKind::Run);
827 assert_eq!(parsed.expression, ".foo");
828 assert_eq!(parsed.files, vec!["a.json"]);
829 }
830
831 #[test]
832 fn root_help_only_documents_default_invocation() {
833 let help = render_help(
834 &parse_args(&["treease".to_string(), "--help".to_string()]).expect("help should parse"),
835 );
836
837 assert!(help.contains("treease [OPTIONS] [EXPRESSION] [FILE]..."));
838 assert!(!help.contains("treease eval"));
839 assert!(!help.contains("eval-all"));
840 }
841
842 #[test]
843 fn root_help_advertises_discovery_commands() {
844 let help = render_help(
845 &parse_args(&["treease".to_string(), "--help".to_string()]).expect("help should parse"),
846 );
847
848 assert!(help.contains("treease [OPTIONS] [EXPRESSION] [FILE]..."));
849 assert!(help.contains("treease help --format json"));
850 assert!(help.contains("treease operators list"));
851 assert!(help.contains("treease formats list"));
852 assert!(help.contains("treease examples list"));
853 assert!(help.contains("treease doctor"));
854 assert!(!help.contains("eval-all"));
855 }
856
857 #[test]
858 fn root_help_json_contains_machine_readable_schema() {
859 let json = spec::root_help_json_value();
860
861 assert_eq!(
862 json.get("name").and_then(serde_json::Value::as_str),
863 Some("treease")
864 );
865 assert_eq!(
866 json.get("usage").and_then(serde_json::Value::as_str),
867 Some("treease [OPTIONS] [EXPRESSION] [FILE]...")
868 );
869 assert!(
870 json.get("subcommands")
871 .and_then(serde_json::Value::as_array)
872 .is_some_and(|subcommands| subcommands.iter().any(|command| {
873 command.get("name").and_then(serde_json::Value::as_str) == Some("operators")
874 }))
875 );
876 assert!(
877 json.get("options")
878 .and_then(serde_json::Value::as_array)
879 .is_some_and(|options| options.iter().any(|option| {
880 option.get("name").and_then(serde_json::Value::as_str) == Some("output-format")
881 }))
882 );
883 assert!(
884 json.get("options")
885 .and_then(serde_json::Value::as_array)
886 .is_some_and(|options| options.iter().all(|option| {
887 option.get("name").and_then(serde_json::Value::as_str) != Some("format")
888 }))
889 );
890 }
891
892 #[test]
893 fn command_spec_can_be_found_by_structured_path() {
894 let command = spec::find_command_spec(&["treease", "operators", "list"])
895 .expect("operators list spec should exist");
896
897 assert_eq!(command.id, spec::CliCommandId::OperatorsList);
898 assert_eq!(command.name, "list");
899 assert_eq!(
900 command.segments,
901 vec![
902 "treease".to_string(),
903 "operators".to_string(),
904 "list".to_string()
905 ]
906 );
907 assert_eq!(command.path, "treease operators list");
908 }
909
910 #[test]
911 fn root_spec_does_not_expose_format_option() {
912 let root = spec::root_command_spec();
913
914 assert!(root.options.iter().all(|option| option.name != "format"));
915 assert!(root.options.iter().any(|option| option.name == "help"));
916 }
917
918 #[test]
919 fn help_command_exposes_help_only_format_option() {
920 let help_command =
921 spec::find_command_spec(&["treease", "help"]).expect("help command should exist");
922 let format_option = help_command
923 .options
924 .iter()
925 .find(|option| option.name == "format")
926 .expect("format option should exist");
927
928 assert_eq!(help_command.id, spec::CliCommandId::Help);
929 assert!(format_option.takes_value);
930 assert_eq!(format_option.value_name.as_deref(), Some("FORMAT"));
931 assert_eq!(format_option.scope, spec::CliOptionScope::HelpOnly);
932 }
933
934 #[test]
935 fn format_and_indent_options_parse() {
936 let raw = vec![
937 "treease".to_string(),
938 "-p".to_string(),
939 "yaml".to_string(),
940 "-o".to_string(),
941 "json".to_string(),
942 "-I".to_string(),
943 "2".to_string(),
944 ".foo".to_string(),
945 ];
946 let parsed = parse_args(&raw).expect("parse should succeed");
947 assert_eq!(parsed.input_format.as_deref(), Some("yaml"));
948 assert_eq!(parsed.output_format.as_deref(), Some("json"));
949 assert_eq!(parsed.indent, Some(2));
950 assert_eq!(parsed.expression, ".foo");
951 }
952
953 #[test]
954 fn input_format_guess_prefers_explicit_override() {
955 let parsed = parse_args(&[
956 "treease".to_string(),
957 "--input-format".to_string(),
958 "yaml".to_string(),
959 ".foo".to_string(),
960 ])
961 .expect("parse should succeed");
962 let payload = InputPayload {
963 name: "data.json".to_string(),
964 bytes: br#"{"foo":1}"#.to_vec(),
965 };
966
967 let input = resolve_input_format(&parsed, &payload).expect("input format should resolve");
968
969 assert_eq!(input, "yaml");
970 }
971
972 #[test]
973 fn input_format_guess_uses_filename_extension_before_content() {
974 let payload = InputPayload {
975 name: "data.yaml".to_string(),
976 bytes: br#"{"foo":1}"#.to_vec(),
977 };
978
979 let guessed = guess_input_format(&payload);
980
981 assert_eq!(guessed.as_deref(), Some("yaml"));
982 }
983
984 #[test]
985 fn input_format_guess_uses_content_for_stdin() {
986 let payload = InputPayload {
987 name: "<stdin>".to_string(),
988 bytes: b"foo: 1\nbar: 2\n".to_vec(),
989 };
990
991 let guessed = guess_input_format(&payload);
992
993 assert_eq!(guessed.as_deref(), Some("yaml"));
994 }
995
996 #[test]
997 fn input_format_guess_uses_content_for_suffixless_file() {
998 let payload = InputPayload {
999 name: "config".to_string(),
1000 bytes: b"foo = 1\nbar = 2\n".to_vec(),
1001 };
1002
1003 let guessed = guess_input_format(&payload);
1004
1005 assert_eq!(guessed.as_deref(), Some("toml"));
1006 }
1007
1008 #[test]
1009 fn input_format_defaults_to_json_when_guess_fails() {
1010 let parsed =
1011 parse_args(&["treease".to_string(), ".foo".to_string()]).expect("parse should succeed");
1012 let payload = InputPayload {
1013 name: "README".to_string(),
1014 bytes: b"plain text without recognizable structure".to_vec(),
1015 };
1016
1017 let input = resolve_input_format(&parsed, &payload).expect("input format should resolve");
1018
1019 assert_eq!(input, "json");
1020 }
1021
1022 #[test]
1023 fn suffixless_yaml_input_executes_without_explicit_input_format() {
1024 let parsed =
1025 parse_args(&["treease".to_string(), ".foo".to_string()]).expect("parse should succeed");
1026 let inputs = vec![InputPayload {
1027 name: "sample".to_string(),
1028 bytes: b"foo: 1\n".to_vec(),
1029 }];
1030
1031 let output = execute_command(&parsed, &inputs).expect("command should succeed");
1032
1033 assert_eq!(String::from_utf8(output).unwrap(), "1\n");
1034 }
1035
1036 #[test]
1037 fn null_input_evaluates_expression_without_stdin() {
1038 let parsed = parse_args(&["treease".to_string(), "-n".to_string(), "1".to_string()])
1039 .expect("parse should succeed");
1040 let output = execute_command(&parsed, &[]).expect("command should succeed");
1041 assert_eq!(String::from_utf8(output).unwrap(), "1\n");
1042 }
1043
1044 #[test]
1045 fn missing_path_renders_null() {
1046 let parsed = parse_args(&[
1047 "treease".to_string(),
1048 "-e".to_string(),
1049 ".missing".to_string(),
1050 ])
1051 .expect("parse should succeed");
1052 let inputs = vec![InputPayload {
1053 name: "<stdin>".to_string(),
1054 bytes: b"{\"foo\":1}\n".to_vec(),
1055 }];
1056
1057 let output = execute_command(&parsed, &inputs).expect("command should succeed");
1058
1059 assert_eq!(String::from_utf8(output.clone()).unwrap(), "null\n");
1060 assert_eq!(compute_exit_status(parsed.exit_status, &output), 1);
1061 }
1062
1063 #[test]
1064 fn help_json_command_parses_without_reading_stdin() {
1065 let parsed = parse_args(&[
1066 "treease".to_string(),
1067 "help".to_string(),
1068 "--format".to_string(),
1069 "json".to_string(),
1070 ])
1071 .expect("help json should parse");
1072
1073 assert_eq!(parsed.command, CommandKind::Help);
1074 assert_eq!(parsed.metadata_format.as_deref(), Some("json"));
1075 assert!(parsed.files.is_empty());
1076 }
1077
1078 #[test]
1079 fn operators_get_command_parses_name_and_json_format() {
1080 let parsed = parse_args(&[
1081 "treease".to_string(),
1082 "operators".to_string(),
1083 "get".to_string(),
1084 "map".to_string(),
1085 "--format".to_string(),
1086 "json".to_string(),
1087 ])
1088 .expect("operators get should parse");
1089
1090 assert_eq!(parsed.command, CommandKind::OperatorsGet);
1091 assert_eq!(parsed.metadata_target.as_deref(), Some("map"));
1092 assert_eq!(parsed.metadata_format.as_deref(), Some("json"));
1093 }
1094
1095 #[test]
1096 fn formats_list_command_parses_json_format() {
1097 let parsed = parse_args(&[
1098 "treease".to_string(),
1099 "formats".to_string(),
1100 "list".to_string(),
1101 "--format".to_string(),
1102 "json".to_string(),
1103 ])
1104 .expect("formats list should parse");
1105
1106 assert_eq!(parsed.command, CommandKind::FormatsList);
1107 assert_eq!(parsed.metadata_format.as_deref(), Some("json"));
1108 }
1109
1110 #[test]
1111 fn discovery_parent_command_requires_leaf_subcommand() {
1112 let error = parse_args(&["treease".to_string(), "operators".to_string()])
1113 .expect_err("parent discovery command should fail");
1114
1115 match error {
1116 CliError::Eval(message) => {
1117 assert!(message.contains("subcommand"));
1118 }
1119 other => panic!("unexpected error: {other:?}"),
1120 }
1121 }
1122
1123 #[test]
1124 fn operators_get_select_returns_json_metadata() {
1125 let parsed = parse_args(&[
1126 "treease".to_string(),
1127 "operators".to_string(),
1128 "get".to_string(),
1129 "select".to_string(),
1130 "--format".to_string(),
1131 "json".to_string(),
1132 ])
1133 .expect("operators get should parse");
1134
1135 let output = execute_metadata_command(&parsed).expect("metadata should render");
1136 let value: serde_json::Value = serde_json::from_slice(&output).expect("valid json");
1137
1138 assert_eq!(value["name"], "select");
1139 assert_eq!(value["category"], "special");
1140 assert_eq!(value["syntax"], "select(EXPR)");
1141 }
1142
1143 #[test]
1144 fn formats_get_yaml_returns_json_metadata() {
1145 let parsed = parse_args(&[
1146 "treease".to_string(),
1147 "formats".to_string(),
1148 "get".to_string(),
1149 "yaml".to_string(),
1150 "--format".to_string(),
1151 "json".to_string(),
1152 ])
1153 .expect("formats get should parse");
1154
1155 let output = execute_metadata_command(&parsed).expect("metadata should render");
1156 let value: serde_json::Value = serde_json::from_slice(&output).expect("valid json");
1157
1158 assert_eq!(value["name"], "yaml");
1159 assert_eq!(value["can_decode"], true);
1160 assert_eq!(value["can_encode"], true);
1161 }
1162
1163 #[test]
1164 fn unsupported_format_error_has_code_and_hint() {
1165 let err = CliError::UnsupportedFormat("foo".to_string());
1166 let report = errors::error_report(&err);
1167
1168 assert_eq!(report.code, "UNSUPPORTED_FORMAT");
1169 assert!(report.hint.contains("treease formats list"));
1170 assert!(errors::render_text(&err).contains("UNSUPPORTED_FORMAT"));
1171 }
1172
1173 #[test]
1174 fn unknown_flag_error_has_code_and_hint() {
1175 let err = CliError::UnknownFlag("--wat".to_string());
1176 let report = errors::error_report(&err);
1177
1178 assert_eq!(report.code, "UNKNOWN_FLAG");
1179 assert!(report.hint.contains("treease --help"));
1180 }
1181
1182 #[test]
1183 fn unknown_command_error_has_code_and_migration_hint() {
1184 let err = CliError::UnknownCommand("eval-all".to_string());
1185 let report = errors::error_report(&err);
1186
1187 assert_eq!(report.code, "UNKNOWN_COMMAND");
1188 assert!(report.hint.contains("legacy eval subcommands were removed"));
1189 assert!(
1190 report
1191 .hint
1192 .contains("treease [OPTIONS] [EXPRESSION] [FILE]...")
1193 );
1194 }
1195
1196 #[test]
1197 fn removed_legacy_eval_commands_are_rejected() {
1198 for command in ["e", "eval-all", "ea"] {
1199 let error = parse_args(&["treease".to_string(), command.to_string()])
1200 .expect_err("legacy eval command should fail");
1201
1202 match error {
1203 CliError::UnknownCommand(name) => assert_eq!(name, command),
1204 other => panic!("unexpected error for {command}: {other:?}"),
1205 }
1206 }
1207 }
1208
1209 #[test]
1210 fn help_operators_text_targets_command_specific_help() {
1211 let help = render_help(
1212 &parse_args(&[
1213 "treease".to_string(),
1214 "help".to_string(),
1215 "operators".to_string(),
1216 ])
1217 .expect("help operators should parse"),
1218 );
1219
1220 assert!(help.contains("treease operators <COMMAND>"));
1221 assert!(help.contains("treease operators list"));
1222 assert!(!help.contains("treease [OPTIONS] [EXPRESSION] [FILE]..."));
1223 }
1224
1225 #[test]
1226 fn web_command_parses_expression_file_and_format_options() {
1227 let parsed = parse_args(&[
1228 "treease".to_string(),
1229 "web".to_string(),
1230 "-p".to_string(),
1231 "yaml".to_string(),
1232 "-o".to_string(),
1233 "json".to_string(),
1234 "-I".to_string(),
1235 "2".to_string(),
1236 ".service".to_string(),
1237 "config.yaml".to_string(),
1238 ])
1239 .expect("web command should parse");
1240
1241 assert_eq!(parsed.command, CommandKind::Web);
1242 assert_eq!(parsed.expression, ".service");
1243 assert_eq!(parsed.files, vec!["config.yaml"]);
1244 assert_eq!(parsed.input_format.as_deref(), Some("yaml"));
1245 assert_eq!(parsed.output_format.as_deref(), Some("json"));
1246 assert_eq!(parsed.indent, Some(2));
1247 }
1248
1249 #[test]
1250 fn web_command_accepts_stdin_source() {
1251 let parsed = parse_args(&[
1252 "treease".to_string(),
1253 "web".to_string(),
1254 ".".to_string(),
1255 "-".to_string(),
1256 ])
1257 .expect("web stdin should parse");
1258
1259 assert_eq!(parsed.command, CommandKind::Web);
1260 assert_eq!(parsed.files, vec!["-"]);
1261 }
1262
1263 #[test]
1264 fn web_command_rejects_multiple_input_sources() {
1265 let error = parse_args(&[
1266 "treease".to_string(),
1267 "web".to_string(),
1268 ".".to_string(),
1269 "a.yaml".to_string(),
1270 "b.yaml".to_string(),
1271 ])
1272 .expect_err("web should reject multiple files");
1273
1274 match error {
1275 CliError::InvalidWebInputCount => {}
1276 other => panic!("unexpected error: {other:?}"),
1277 }
1278 }
1279
1280 #[test]
1281 fn root_invocation_with_web_file_name_is_not_prechecked_as_web_command() {
1282 let parsed = parse_args(&[
1283 "treease".to_string(),
1284 ".".to_string(),
1285 "web".to_string(),
1286 "a.yaml".to_string(),
1287 "b.yaml".to_string(),
1288 "c.yaml".to_string(),
1289 ])
1290 .expect("root invocation should parse");
1291
1292 assert_eq!(parsed.command, CommandKind::Run);
1293 assert_eq!(parsed.expression, ".");
1294 assert_eq!(parsed.files, vec!["web", "a.yaml", "b.yaml", "c.yaml"]);
1295 }
1296
1297 #[test]
1298 fn web_command_rejects_root_execution_flags_that_do_not_apply() {
1299 let error = parse_args(&[
1300 "treease".to_string(),
1301 "-e".to_string(),
1302 "web".to_string(),
1303 ".".to_string(),
1304 "a.yaml".to_string(),
1305 ])
1306 .expect_err("web should reject exit-status");
1307
1308 match error {
1309 CliError::UnsupportedWebFlag("--exit-status") => {}
1310 other => panic!("unexpected error: {other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn web_command_preserves_unknown_flag_error() {
1316 let error = parse_args(&[
1317 "treease".to_string(),
1318 "web".to_string(),
1319 "--wat".to_string(),
1320 ".".to_string(),
1321 "file.yaml".to_string(),
1322 ])
1323 .expect_err("unknown web flag should fail");
1324
1325 match error {
1326 CliError::UnknownFlag(flag) => assert_eq!(flag, "--wat"),
1327 other => panic!("unexpected error: {other:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn web_command_preserves_unknown_flag_with_value_error() {
1333 let error = parse_args(&[
1334 "treease".to_string(),
1335 "web".to_string(),
1336 "--wat".to_string(),
1337 "value".to_string(),
1338 ".".to_string(),
1339 "file.yaml".to_string(),
1340 ])
1341 .expect_err("unknown web flag should fail before input count validation");
1342
1343 match error {
1344 CliError::UnknownFlag(flag) => assert_eq!(flag, "--wat"),
1345 other => panic!("unexpected error: {other:?}"),
1346 }
1347 }
1348
1349 #[test]
1350 fn web_payload_uses_expression_output_and_output_format() {
1351 let parsed = parse_args(&[
1352 "treease".to_string(),
1353 "web".to_string(),
1354 "-o".to_string(),
1355 "json".to_string(),
1356 ".foo".to_string(),
1357 "input.yaml".to_string(),
1358 ])
1359 .expect("web should parse");
1360 let inputs = vec![InputPayload {
1361 name: "input.yaml".to_string(),
1362 bytes: b"foo:\n bar: 1\n".to_vec(),
1363 }];
1364
1365 let payload = web_payload::build_cli_graph_result_payload(&parsed, &inputs)
1366 .expect("web payload should be produced");
1367
1368 assert_eq!(payload.source_label, "input.yaml");
1369 assert_eq!(payload.expression, ".foo");
1370 assert_eq!(payload.language, "json");
1371 assert_eq!(payload.text, "{\"bar\": 1}\n");
1372 }
1373
1374 #[test]
1375 fn web_payload_supports_stdin_and_defaults_to_yaml() {
1376 let parsed = parse_args(&[
1377 "treease".to_string(),
1378 "web".to_string(),
1379 ".".to_string(),
1380 "-".to_string(),
1381 ])
1382 .expect("web stdin should parse");
1383 let inputs = vec![InputPayload {
1384 name: "<stdin>".to_string(),
1385 bytes: b"foo: 1\n".to_vec(),
1386 }];
1387
1388 let payload = web_payload::build_cli_graph_result_payload(&parsed, &inputs)
1389 .expect("web payload should be produced");
1390
1391 assert_eq!(payload.source_label, "<stdin>");
1392 assert_eq!(payload.language, "yaml");
1393 assert_eq!(payload.text, "foo: 1\n");
1394 }
1395
1396 #[test]
1397 fn web_command_rejects_subcommand_position_unsupported_flags() {
1398 for (flag, expected) in [
1399 ("--null-input", "--null-input"),
1400 ("--exit-status", "--exit-status"),
1401 ("--inplace", "--inplace"),
1402 ] {
1403 let error = parse_args(&[
1404 "treease".to_string(),
1405 "web".to_string(),
1406 flag.to_string(),
1407 ".".to_string(),
1408 "file.yaml".to_string(),
1409 ])
1410 .expect_err("web should reject unsupported execution flag");
1411
1412 match error {
1413 CliError::UnsupportedWebFlag(actual) => assert_eq!(actual, expected),
1414 other => panic!("unexpected error for {flag}: {other:?}"),
1415 }
1416 }
1417 }
1418
1419 #[test]
1420 fn help_json_advertises_web_command() {
1421 let json = spec::root_help_json_value();
1422 let subcommands = json
1423 .get("subcommands")
1424 .and_then(serde_json::Value::as_array)
1425 .expect("root subcommands should be present");
1426
1427 let web_command = subcommands
1428 .iter()
1429 .find(|command| command.get("name").and_then(serde_json::Value::as_str) == Some("web"))
1430 .expect("web command should be advertised");
1431
1432 assert!(subcommands.iter().any(|command| {
1433 command.get("name").and_then(serde_json::Value::as_str) == Some("web")
1434 && command.get("usage").and_then(serde_json::Value::as_str)
1435 == Some("treease web [OPTIONS] <EXPRESSION> <FILE|->")
1436 }));
1437
1438 let file_argument = web_command
1439 .get("arguments")
1440 .and_then(serde_json::Value::as_array)
1441 .and_then(|arguments| {
1442 arguments.iter().find(|argument| {
1443 argument.get("name").and_then(serde_json::Value::as_str) == Some("file")
1444 })
1445 })
1446 .expect("web file argument should be present");
1447
1448 assert_eq!(
1449 file_argument
1450 .get("repeated")
1451 .and_then(serde_json::Value::as_bool),
1452 Some(false)
1453 );
1454 assert_eq!(
1455 file_argument
1456 .get("multiple")
1457 .and_then(serde_json::Value::as_bool),
1458 Some(false)
1459 );
1460 }
1461}