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