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}
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}