Skip to main content

treease_cli/
lib.rs

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}