1use std::ffi::OsString;
2
3use rootcause::report;
4use ytil_sys::pico_args::Arguments;
5
6use crate::cmds::tok::Opts;
7
8pub mod codex;
9pub mod sessions;
10pub mod tok;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum Help {
14 Root,
15 Sessions,
16 SessionsList,
17 Codex,
18 Tok,
19}
20
21impl Help {
22 pub fn from_args(args: &[OsString]) -> Self {
23 match args.first().map(|arg| arg.to_string_lossy()).as_deref() {
24 Some("sessions") if args.get(1).is_some_and(|arg| arg == "list") => Self::SessionsList,
25 Some("sessions") => Self::Sessions,
26 Some("codex") => Self::Codex,
27 Some("tok") => Self::Tok,
28 _ => Self::Root,
29 }
30 }
31
32 pub const fn text(self) -> &'static str {
33 match self {
34 Self::Root => include_str!("../help.txt"),
35 Self::Sessions => include_str!("../help/sessions/help.txt"),
36 Self::SessionsList => include_str!("../help/sessions/list/help.txt"),
37 Self::Codex => include_str!("../help/codex/help.txt"),
38 Self::Tok => include_str!("../help/tok/help.txt"),
39 }
40 }
41}
42
43#[derive(Debug, Eq, PartialEq)]
44pub enum Cmd {
45 Help(Help),
46 SessionsList,
47 SessionsListJson(Vec<String>),
48 CodexCompact,
49 Tok(Opts),
50}
51
52impl Cmd {
53 pub fn from_env() -> rootcause::Result<Self> {
54 let args = Arguments::from_env();
55 let help = Help::from_args(&args.clone().finish());
56 Self::try_from(args).inspect_err(|_| eprintln!("{}", help.text()))
57 }
58
59 fn parse_sessions(mut args: Arguments) -> rootcause::Result<Self> {
60 match args.subcommand()?.as_deref() {
61 Some("list") if args.contains("--json") => Ok(Self::SessionsListJson(strings(args.finish()))),
62 Some("list") if args.finish().is_empty() => Ok(Self::SessionsList),
63 Some("list") => Err(report!("unsupported agg sessions list command")),
64 Some(_) => Err(report!("unsupported agg sessions command")),
65 None => Err(report!("missing agg sessions command")),
66 }
67 }
68
69 fn parse_codex(mut args: Arguments) -> rootcause::Result<Self> {
70 if args.contains("--compact") && args.finish().is_empty() {
71 Ok(Self::CodexCompact)
72 } else {
73 Err(report!("unsupported agg codex command"))
74 }
75 }
76}
77
78impl TryFrom<Arguments> for Cmd {
79 type Error = rootcause::Report;
80
81 fn try_from(mut args: Arguments) -> Result<Self, Self::Error> {
82 if args.contains("--help") {
83 return Ok(Self::Help(Help::from_args(&args.clone().finish())));
84 }
85
86 let Some(command) = args.subcommand()? else {
87 return if args.finish().is_empty() {
88 Ok(Self::SessionsList)
89 } else {
90 Err(report!("unsupported agg command"))
91 };
92 };
93
94 match command.as_str() {
95 "sessions" => Self::parse_sessions(args),
96 "codex" => Self::parse_codex(args),
97 "tok" => Ok(Self::Tok(Opts::try_from(args.finish())?)),
98 _ => Err(report!("unsupported agg command")),
99 }
100 }
101}
102
103fn strings(args: Vec<OsString>) -> Vec<String> {
104 args.into_iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
105}
106
107#[cfg(test)]
108mod tests {
109 use test_that::prelude::*;
110
111 use super::*;
112 use crate::cmds::tok::Input;
113
114 #[rstest::rstest]
115 #[case::bare(&[], Cmd::SessionsList)]
116 #[case::sessions_list(&["sessions", "list"], Cmd::SessionsList)]
117 #[case::codex_compact(&["codex", "--compact"], Cmd::CodexCompact)]
118 #[case::help(&["sessions", "--help"], Cmd::Help(Help::Sessions))]
119 #[case::tok_file(&["tok", "prompt.txt"], Cmd::Tok(Opts {
120 encoding: "o200k_base".to_owned(),
121 input: Input::File(std::path::PathBuf::from("prompt.txt")),
122 }))]
123 #[case::tok_text(&["tok", "--text", "hello"], Cmd::Tok(Opts {
124 encoding: "o200k_base".to_owned(),
125 input: Input::Text("hello".to_owned()),
126 }))]
127 #[case::tok_encoding(&["tok", "--encoding", "cl100k_base", "-"], Cmd::Tok(Opts {
128 encoding: "cl100k_base".to_owned(),
129 input: Input::Stdin,
130 }))]
131 fn test_parse_known_commands(#[case] args: &[&str], #[case] expected: Cmd) {
132 assert_that!(parse(args), ok(eq(expected)));
133 }
134
135 #[test]
136 fn test_parse_sessions_list_json_keeps_session_args() {
137 assert_that!(
138 parse(&["sessions", "list", "--json", "--session", "codex:session-id"]),
139 ok(eq(Cmd::SessionsListJson(vec![
140 "--session".to_owned(),
141 "codex:session-id".to_owned()
142 ])))
143 );
144 }
145
146 #[rstest::rstest]
147 #[case::unknown(&["unknown"])]
148 #[case::missing_sessions_subcommand(&["sessions"])]
149 #[case::unexpected_sessions_list_arg(&["sessions", "list", "unexpected"])]
150 #[case::missing_codex_flag(&["codex"])]
151 fn test_parse_rejects_invalid_commands(#[case] args: &[&str]) {
152 assert_that!(parse(args), err(anything()));
153 }
154
155 #[rstest::rstest]
156 #[case::root(&["--help"], Help::Root)]
157 #[case::sessions(&["sessions", "--help"], Help::Sessions)]
158 #[case::sessions_list(&["sessions", "list", "--help"], Help::SessionsList)]
159 #[case::codex(&["codex", "--help"], Help::Codex)]
160 #[case::tok(&["tok", "--help"], Help::Tok)]
161 fn test_help_when_command_path_varies_selects_the_deepest_command(#[case] args: &[&str], #[case] expected: Help) {
162 let raw = args.iter().map(OsString::from).collect::<Vec<_>>();
163 assert_that!(Help::from_args(&raw), eq(expected));
164 }
165
166 fn parse(args: &[&str]) -> rootcause::Result<Cmd> {
167 Cmd::try_from(Arguments::from_vec(args.iter().map(OsString::from).collect()))
168 }
169}