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}