Skip to main content

call_coding_clis/invoke/
plan.rs

1use crate::exec::{CommandSpec, Runner};
2use crate::output::{parse_transcript_for_runner, schema_name_for_runner, Transcript};
3use crate::parser::{parse_args, resolve_command, resolve_output_mode, CccConfig};
4use std::collections::BTreeMap;
5use std::error::Error as StdError;
6use std::fmt;
7use std::path::Path;
8
9use super::request::{OutputMode, Request, RunnerKind};
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct Plan {
13    command_spec: CommandSpec,
14    runner: RunnerKind,
15    output_mode: OutputMode,
16    warnings: Vec<String>,
17}
18
19impl Plan {
20    pub fn command_spec(&self) -> &CommandSpec {
21        &self.command_spec
22    }
23
24    pub fn runner(&self) -> RunnerKind {
25        self.runner
26    }
27
28    pub fn output_mode(&self) -> OutputMode {
29        self.output_mode
30    }
31
32    pub fn warnings(&self) -> &[String] {
33        &self.warnings
34    }
35}
36
37#[derive(Debug)]
38pub enum Error {
39    InvalidRequest(String),
40    Config(String),
41    Spawn(std::io::Error),
42    ToolFailed { exit_code: i32, stderr: String },
43    OutputParse(String),
44}
45
46impl fmt::Display for Error {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Error::InvalidRequest(message) => write!(f, "invalid request: {message}"),
50            Error::Config(message) => write!(f, "configuration error: {message}"),
51            Error::Spawn(error) => write!(f, "spawn error: {error}"),
52            Error::ToolFailed { exit_code, stderr } => {
53                write!(f, "tool failed with exit code {exit_code}: {stderr}")
54            }
55            Error::OutputParse(message) => write!(f, "output parse error: {message}"),
56        }
57    }
58}
59
60impl StdError for Error {}
61
62impl From<std::io::Error> for Error {
63    fn from(error: std::io::Error) -> Self {
64        Self::Spawn(error)
65    }
66}
67
68#[derive(Clone, Debug, PartialEq)]
69pub struct Run {
70    plan: Plan,
71    exit_code: i32,
72    stdout: String,
73    stderr: String,
74    parsed_output: Option<Transcript>,
75    timed_out: bool,
76}
77
78impl Run {
79    pub fn plan(&self) -> &Plan {
80        &self.plan
81    }
82
83    pub fn exit_code(&self) -> i32 {
84        self.exit_code
85    }
86
87    pub fn stdout(&self) -> &str {
88        &self.stdout
89    }
90
91    pub fn stderr(&self) -> &str {
92        &self.stderr
93    }
94
95    pub fn parsed_output(&self) -> Option<&Transcript> {
96        self.parsed_output.as_ref()
97    }
98
99    pub fn timed_out(&self) -> bool {
100        self.timed_out
101    }
102
103    pub fn final_text(&self) -> &str {
104        self.parsed_output
105            .as_ref()
106            .map(|output| output.final_text.as_str())
107            .unwrap_or(self.stdout.as_str())
108    }
109}
110
111pub struct Client {
112    config: Option<CccConfig>,
113    binary_overrides: BTreeMap<RunnerKind, String>,
114    runner: Runner,
115}
116
117impl Client {
118    pub fn new() -> Self {
119        Self {
120            config: None,
121            binary_overrides: BTreeMap::new(),
122            runner: Runner::new(),
123        }
124    }
125
126    pub fn with_config(mut self, config: CccConfig) -> Self {
127        self.config = Some(config);
128        self
129    }
130
131    pub fn with_runtime_runner(mut self, runner: Runner) -> Self {
132        self.runner = runner;
133        self
134    }
135
136    pub fn with_binary_override(
137        mut self,
138        runner_kind: RunnerKind,
139        binary: impl Into<String>,
140    ) -> Self {
141        self.binary_overrides.insert(runner_kind, binary.into());
142        self
143    }
144
145    pub fn plan(&self, request: &Request) -> Result<Plan, Error> {
146        let argv = request.to_cli_tokens();
147        let parsed = parse_args(&argv);
148        let config = self.config.clone().unwrap_or_default();
149        let (argv, env, warnings) = resolve_command(&parsed, Some(&config))
150            .map_err(|message| Error::Config(message.to_string()))?;
151        let output_mode = resolve_output_mode(&parsed, Some(&config))
152            .map_err(|message| Error::Config(message.to_string()))
153            .and_then(|mode| {
154                OutputMode::from_cli_value(&mode)
155                    .ok_or_else(|| Error::Config("resolved output mode was not recognized".into()))
156            })?;
157        let runner = runner_kind_from_argv(&argv)
158            .or_else(|| request.runner_kind())
159            .unwrap_or(RunnerKind::OpenCode);
160        let mut command_spec = CommandSpec {
161            argv,
162            stdin_text: None,
163            cwd: None,
164            env,
165            timeout_secs: parsed.timeout_secs,
166        };
167        if let Some(provider) = request.provider() {
168            command_spec
169                .env
170                .insert("CCC_PROVIDER".to_string(), provider.to_string());
171        }
172        if let Some(binary_override) = self.binary_overrides.get(&runner) {
173            if let Some(program) = command_spec.argv.first_mut() {
174                *program = binary_override.clone();
175            }
176        }
177        Ok(Plan {
178            command_spec,
179            runner,
180            output_mode,
181            warnings,
182        })
183    }
184
185    pub fn run(&self, request: &Request) -> Result<Run, Error> {
186        let run = self.run_unchecked(request)?;
187        if run.exit_code != 0 {
188            return Err(Error::ToolFailed {
189                exit_code: run.exit_code,
190                stderr: run.stderr.clone(),
191            });
192        }
193        Ok(run)
194    }
195
196    pub fn run_unchecked(&self, request: &Request) -> Result<Run, Error> {
197        let plan = self.plan(request)?;
198        Ok(self.run_preplanned(plan))
199    }
200
201    pub fn stream<F>(&self, request: &Request, on_event: F) -> Result<Run, Error>
202    where
203        F: FnMut(&str, &str) + Send + 'static,
204    {
205        let run = self.stream_unchecked(request, on_event)?;
206        if run.exit_code != 0 {
207            return Err(Error::ToolFailed {
208                exit_code: run.exit_code,
209                stderr: run.stderr.clone(),
210            });
211        }
212        Ok(run)
213    }
214
215    pub fn stream_unchecked<F>(&self, request: &Request, on_event: F) -> Result<Run, Error>
216    where
217        F: FnMut(&str, &str) + Send + 'static,
218    {
219        let plan = self.plan(request)?;
220        Ok(self.stream_preplanned(plan, on_event))
221    }
222
223    fn run_preplanned(&self, plan: Plan) -> Run {
224        let completed = self.runner.run(plan.command_spec().clone());
225        let parsed_output = if should_parse_output_mode(plan.output_mode()) {
226            if schema_name_for_runner(plan.runner()).is_some() {
227                parse_transcript_for_runner(&completed.stdout, plan.runner())
228            } else {
229                None
230            }
231        } else {
232            None
233        };
234
235        Run {
236            plan,
237            exit_code: completed.exit_code,
238            stdout: completed.stdout,
239            stderr: completed.stderr,
240            parsed_output,
241            timed_out: completed.timed_out,
242        }
243    }
244
245    fn stream_preplanned<F>(&self, plan: Plan, on_event: F) -> Run
246    where
247        F: FnMut(&str, &str) + Send + 'static,
248    {
249        let completed = self.runner.stream(plan.command_spec().clone(), on_event);
250        let parsed_output = if should_parse_output_mode(plan.output_mode()) {
251            if schema_name_for_runner(plan.runner()).is_some() {
252                parse_transcript_for_runner(&completed.stdout, plan.runner())
253            } else {
254                None
255            }
256        } else {
257            None
258        };
259
260        Run {
261            plan,
262            exit_code: completed.exit_code,
263            stdout: completed.stdout,
264            stderr: completed.stderr,
265            parsed_output,
266            timed_out: completed.timed_out,
267        }
268    }
269}
270
271impl Default for Client {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277fn should_parse_output_mode(output_mode: OutputMode) -> bool {
278    matches!(
279        output_mode,
280        OutputMode::Json
281            | OutputMode::StreamJson
282            | OutputMode::Formatted
283            | OutputMode::StreamFormatted
284    )
285}
286
287fn runner_kind_from_argv(argv: &[String]) -> Option<RunnerKind> {
288    let binary = argv.first()?;
289    let name = Path::new(binary)
290        .file_name()
291        .and_then(|value| value.to_str())
292        .unwrap_or(binary.as_str());
293    match name {
294        "opencode" => Some(RunnerKind::OpenCode),
295        "claude" => Some(RunnerKind::Claude),
296        "codex" => Some(RunnerKind::Codex),
297        "kimi" => Some(RunnerKind::Kimi),
298        "cursor-agent" => Some(RunnerKind::Cursor),
299        "gemini" => Some(RunnerKind::Gemini),
300        "roocode" => Some(RunnerKind::RooCode),
301        "crush" => Some(RunnerKind::Crush),
302        _ => None,
303    }
304}