Skip to main content

toon/cli/
args.rs

1use clap::{Parser, ValueEnum};
2use std::path::PathBuf;
3
4/// TOON CLI — Convert between JSON and TOON formats
5#[derive(Parser, Debug)]
6#[command(name = "toon", version, about, long_about = None)]
7#[allow(clippy::struct_excessive_bools)]
8#[command(after_help = "EXAMPLES:
9    toon input.json                  # Encode JSON to TOON (stdout)
10    toon input.toon                  # Decode TOON to JSON (stdout)
11    toon input.json -o output.toon   # Encode to file
12    cat data.json | toon --encode    # Encode from stdin
13    cat data.toon | toon --decode    # Decode from stdin
14    toon input.json --stats          # Show token statistics")]
15pub struct Args {
16    /// Input file path (omit or use "-" to read from stdin)
17    #[arg(value_name = "INPUT")]
18    pub input: Option<PathBuf>,
19
20    /// Output file path (stdout if omitted)
21    #[arg(short, long, value_name = "FILE")]
22    pub output: Option<PathBuf>,
23
24    /// Encode JSON to TOON (auto-detected by default)
25    #[arg(short, long, conflicts_with = "decode")]
26    pub encode: bool,
27
28    /// Decode TOON to JSON (auto-detected by default)
29    #[arg(short, long, conflicts_with = "encode")]
30    pub decode: bool,
31
32    /// Delimiter for arrays: comma (,), tab (\t), or pipe (|)
33    #[arg(long, default_value = ",", value_parser = parse_delimiter)]
34    pub delimiter: char,
35
36    /// Indentation size (spaces)
37    #[arg(long, default_value = "2", value_parser = clap::value_parser!(u8).range(0..=16))]
38    pub indent: u8,
39
40    /// Disable strict mode for decoding (allows lenient parsing)
41    #[arg(long = "no-strict")]
42    pub no_strict: bool,
43
44    /// Key folding mode: off or safe
45    #[arg(long, value_enum, default_value = "off")]
46    pub key_folding: KeyFoldingArg,
47
48    /// Maximum folded segment count when key folding is enabled
49    #[arg(long, value_name = "N")]
50    pub flatten_depth: Option<usize>,
51
52    /// Path expansion mode: off or safe (decode only)
53    #[arg(long, value_enum, default_value = "off")]
54    pub expand_paths: ExpandPathsArg,
55
56    /// Show token statistics (encode only)
57    #[arg(long)]
58    pub stats: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
62pub enum KeyFoldingArg {
63    Off,
64    Safe,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
68pub enum ExpandPathsArg {
69    Off,
70    Safe,
71}
72
73fn parse_delimiter(s: &str) -> Result<char, String> {
74    match s {
75        "," | "comma" => Ok(','),
76        "|" | "pipe" => Ok('|'),
77        "\\t" | "\t" | "tab" => Ok('\t'),
78        _ => Err(format!(
79            "Invalid delimiter \"{s}\". Valid delimiters are: comma (,), tab (\\t), pipe (|)"
80        )),
81    }
82}
83
84impl Args {
85    /// Detect the operation mode based on flags and file extension.
86    #[must_use]
87    pub fn detect_mode(&self) -> Mode {
88        // Explicit flags take precedence
89        if self.encode {
90            return Mode::Encode;
91        }
92        if self.decode {
93            return Mode::Decode;
94        }
95
96        // Auto-detect based on file extension
97        if let Some(ref path) = self.input
98            && let Some(ext) = path.extension()
99        {
100            let ext = ext.to_string_lossy().to_lowercase();
101            if ext == "json" {
102                return Mode::Encode;
103            }
104            if ext == "toon" {
105                return Mode::Decode;
106            }
107        }
108
109        // Default to encode
110        Mode::Encode
111    }
112
113    /// Returns true if reading from stdin.
114    #[must_use]
115    pub fn is_stdin(&self) -> bool {
116        self.input.is_none() || self.input.as_ref().is_some_and(|p| p.as_os_str() == "-")
117    }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Mode {
122    Encode,
123    Decode,
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_parse_delimiter() {
132        assert_eq!(parse_delimiter(","), Ok(','));
133        assert_eq!(parse_delimiter("|"), Ok('|'));
134        assert_eq!(parse_delimiter("\\t"), Ok('\t'));
135        assert_eq!(parse_delimiter("tab"), Ok('\t'));
136        assert!(parse_delimiter("invalid").is_err());
137    }
138
139    #[test]
140    fn test_detect_mode_explicit_flags() {
141        let args = Args {
142            input: None,
143            output: None,
144            encode: true,
145            decode: false,
146            delimiter: ',',
147            indent: 2,
148            no_strict: false,
149            key_folding: KeyFoldingArg::Off,
150            flatten_depth: None,
151            expand_paths: ExpandPathsArg::Off,
152            stats: false,
153        };
154        assert_eq!(args.detect_mode(), Mode::Encode);
155    }
156
157    #[test]
158    fn test_detect_mode_by_extension() {
159        let args = Args {
160            input: Some(PathBuf::from("data.toon")),
161            output: None,
162            encode: false,
163            decode: false,
164            delimiter: ',',
165            indent: 2,
166            no_strict: false,
167            key_folding: KeyFoldingArg::Off,
168            flatten_depth: None,
169            expand_paths: ExpandPathsArg::Off,
170            stats: false,
171        };
172        assert_eq!(args.detect_mode(), Mode::Decode);
173    }
174}