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