Skip to main content

toon/cli/
mod.rs

1pub mod args;
2pub mod conversion;
3pub mod json_stream;
4pub mod json_stringify;
5
6use crate::error::{Result, ToonError};
7use crate::options::{DecodeOptions, EncodeOptions, ExpandPathsMode, KeyFoldingMode};
8use args::{Args, ExpandPathsArg, KeyFoldingArg, Mode};
9use clap::Parser;
10use std::fs::File;
11use std::io::{self, BufWriter, Read, Write};
12use std::path::Path;
13
14/// Runs the CLI entrypoint.
15///
16/// # Errors
17///
18/// Returns an error if parsing, encoding, decoding, or I/O fails.
19pub fn run() -> Result<()> {
20    let args = Args::parse();
21    let mode = args.detect_mode();
22
23    match mode {
24        Mode::Encode => run_encode(&args),
25        Mode::Decode => run_decode(&args),
26    }
27}
28
29fn run_encode(args: &Args) -> Result<()> {
30    // Read input (JSON)
31    let input = read_input(args)?;
32
33    // Build encode options
34    let options = EncodeOptions {
35        indent: Some(usize::from(args.indent)),
36        delimiter: Some(args.delimiter),
37        key_folding: Some(match args.key_folding {
38            KeyFoldingArg::Off => KeyFoldingMode::Off,
39            KeyFoldingArg::Safe => KeyFoldingMode::Safe,
40        }),
41        flatten_depth: args.flatten_depth,
42        replacer: None,
43    };
44
45    // Encode
46    let toon_lines = conversion::encode_to_toon_lines(&input, Some(options))?;
47
48    // Output
49    if args.stats {
50        let toon_output = toon_lines.join("\n");
51        write_output(args, toon_output.as_bytes())?;
52
53        // Calculate token estimates (simple heuristic: ~4 chars per token)
54        let json_tokens = estimate_tokens(&input);
55        let toon_tokens = estimate_tokens(&toon_output);
56        let diff = json_tokens.saturating_sub(toon_tokens);
57        #[allow(clippy::cast_precision_loss)]
58        let percent = if json_tokens > 0 {
59            (diff as f64 / json_tokens as f64) * 100.0
60        } else {
61            0.0
62        };
63
64        // Print stats to stderr (so stdout can be piped)
65        eprintln!();
66        eprintln!("Token estimates: ~{json_tokens} (JSON) → ~{toon_tokens} (TOON)");
67        if diff > 0 {
68            eprintln!("Saved ~{diff} tokens (-{percent:.1}%)");
69        }
70    } else {
71        // Streaming output
72        write_lines(args, &toon_lines)?;
73    }
74
75    // Success message to stderr if writing to file
76    if let Some(ref output_path) = args.output {
77        let input_label = format_input_label(args);
78        let output_label = output_path.display();
79        eprintln!("Encoded `{input_label}` → `{output_label}`");
80    }
81
82    Ok(())
83}
84
85fn run_decode(args: &Args) -> Result<()> {
86    // Read input (TOON)
87    let input = read_input(args)?;
88
89    // Build decode options
90    let options = DecodeOptions {
91        indent: Some(usize::from(args.indent)),
92        strict: Some(!args.no_strict),
93        expand_paths: Some(match args.expand_paths {
94            ExpandPathsArg::Off => ExpandPathsMode::Off,
95            ExpandPathsArg::Safe => ExpandPathsMode::Safe,
96        }),
97    };
98
99    // Decode to JSON chunks
100    let json_chunks = conversion::decode_to_json_chunks(&input, Some(options))?;
101
102    // Write output
103    write_chunks(args, &json_chunks)?;
104
105    // Success message to stderr if writing to file
106    if let Some(ref output_path) = args.output {
107        let input_label = format_input_label(args);
108        let output_label = output_path.display();
109        eprintln!("Decoded `{input_label}` → `{output_label}`");
110    }
111
112    Ok(())
113}
114
115fn read_input(args: &Args) -> Result<String> {
116    if args.is_stdin() {
117        read_stdin()
118    } else {
119        let path = args
120            .input
121            .as_ref()
122            .ok_or_else(|| ToonError::message("No input file specified"))?;
123        read_file(path)
124    }
125}
126
127fn read_stdin() -> Result<String> {
128    let mut buffer = String::new();
129    io::stdin()
130        .read_to_string(&mut buffer)
131        .map_err(ToonError::stdin_read)?;
132    Ok(buffer)
133}
134
135fn read_file(path: &Path) -> Result<String> {
136    std::fs::read_to_string(path).map_err(|e| ToonError::file_read(path.to_path_buf(), e))
137}
138
139fn write_output(args: &Args, data: &[u8]) -> Result<()> {
140    if let Some(ref path) = args.output {
141        let mut file = File::create(path).map_err(|e| ToonError::file_create(path.clone(), e))?;
142        file.write_all(data)
143            .map_err(|e| ToonError::file_write(path.clone(), e))?;
144        // Add trailing newline for file output
145        file.write_all(b"\n")
146            .map_err(|e| ToonError::file_write(path.clone(), e))?;
147    } else {
148        let stdout = io::stdout();
149        let mut handle = stdout.lock();
150        handle.write_all(data).map_err(ToonError::stdout_write)?;
151        handle.write_all(b"\n").map_err(ToonError::stdout_write)?;
152    }
153    Ok(())
154}
155
156fn write_lines(args: &Args, lines: &[String]) -> Result<()> {
157    if let Some(ref path) = args.output {
158        let file = File::create(path).map_err(|e| ToonError::file_create(path.clone(), e))?;
159        let mut writer = BufWriter::new(file);
160
161        for (i, line) in lines.iter().enumerate() {
162            if i > 0 {
163                writer
164                    .write_all(b"\n")
165                    .map_err(|e| ToonError::file_write(path.clone(), e))?;
166            }
167            writer
168                .write_all(line.as_bytes())
169                .map_err(|e| ToonError::file_write(path.clone(), e))?;
170        }
171        // Trailing newline
172        writer
173            .write_all(b"\n")
174            .map_err(|e| ToonError::file_write(path.clone(), e))?;
175    } else {
176        let stdout = io::stdout();
177        let mut handle = stdout.lock();
178
179        for (i, line) in lines.iter().enumerate() {
180            if i > 0 {
181                handle.write_all(b"\n").map_err(ToonError::stdout_write)?;
182            }
183            handle
184                .write_all(line.as_bytes())
185                .map_err(ToonError::stdout_write)?;
186        }
187        // Trailing newline
188        handle.write_all(b"\n").map_err(ToonError::stdout_write)?;
189    }
190    Ok(())
191}
192
193fn write_chunks(args: &Args, chunks: &[String]) -> Result<()> {
194    if let Some(ref path) = args.output {
195        let file = File::create(path).map_err(|e| ToonError::file_create(path.clone(), e))?;
196        let mut writer = BufWriter::new(file);
197
198        for chunk in chunks {
199            writer
200                .write_all(chunk.as_bytes())
201                .map_err(|e| ToonError::file_write(path.clone(), e))?;
202        }
203        // Trailing newline
204        writer
205            .write_all(b"\n")
206            .map_err(|e| ToonError::file_write(path.clone(), e))?;
207    } else {
208        let stdout = io::stdout();
209        let mut handle = stdout.lock();
210
211        for chunk in chunks {
212            handle
213                .write_all(chunk.as_bytes())
214                .map_err(ToonError::stdout_write)?;
215        }
216        // Trailing newline
217        handle.write_all(b"\n").map_err(ToonError::stdout_write)?;
218    }
219    Ok(())
220}
221
222fn format_input_label(args: &Args) -> String {
223    if args.is_stdin() {
224        "stdin".to_string()
225    } else if let Some(ref path) = args.input {
226        path.display().to_string()
227    } else {
228        "stdin".to_string()
229    }
230}
231
232/// Simple token estimation heuristic (roughly 4 chars per token for English/code).
233/// This matches the behavior of tokenx used in the legacy CLI.
234fn estimate_tokens(text: &str) -> usize {
235    // Simple heuristic: count non-whitespace chars / 4, with minimum of word count
236    let char_estimate = text.chars().filter(|c| !c.is_whitespace()).count() / 4;
237    let word_estimate = text.split_whitespace().count();
238    char_estimate.max(word_estimate).max(1)
239}