Skip to main content

treease_cli/
lib.rs

1use std::env;
2use std::fs;
3use std::io::{self, Read, Write};
4
5mod catalog;
6mod errors;
7mod parser;
8mod spec;
9mod web_assets;
10mod web_payload;
11mod web_server;
12
13use treease_core::core::{
14    Context, CoreError, Encoder, NodeId, Printer, Reader, SystemError, VecPrinterWriter,
15};
16use treease_core::evaluator::StreamEvaluator;
17use treease_core::formats::{
18    CsvEncoder, Encode, FormatPreferences, JavascriptEncoder, JsonEncoder, PythonEncoder,
19    TomlEncoder, YamlEncoder,
20};
21
22#[derive(Debug, Clone)]
23struct ParsedArgs {
24    command: CommandKind,
25    expression: String,
26    files: Vec<String>,
27    help: bool,
28    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}