Skip to main content

treease_cli/
lib.rs

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