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