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