Skip to main content

agg/
cmds.rs

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}